18 Commits

Autor SHA1 Mensagem Data
Simon Kusterer d658a66f15 some rewriting 2015-01-24 20:55:38 +01:00
Simon Kusterer 4eacc487d8 engine should be robuster now 2015-01-24 18:21:50 +01:00
Simon Kusterer 7438113410 even nicer :D 2015-01-21 07:51:41 +01:00
Simon Kusterer e5763a7710 refactored unformatTime utility function 2015-01-21 00:10:55 +01:00
Simon Kusterer fbf9141a5a added utility function guessIp ( #56 ) 2015-01-20 23:12:11 +01:00
Simon Kusterer c801f04b3b torrent plugin now uses the torrent-stream module directly (instead of peerflix) 2015-01-19 23:50:54 +01:00
Simon Kusterer 93e4d87b35 reworked directories plugin 2015-01-18 18:25:00 +01:00
Simon Kusterer b601e66a6e start express right away 2015-01-18 17:35:51 +01:00
Simon Kusterer ca0213f54b reworked localfile plugin 2015-01-18 17:33:37 +01:00
Simon Kusterer 7b656d24c2 reworked youtube-playlist plugin to support new castnow api 2015-01-18 16:23:24 +01:00
Simon Kusterer 62120bba13 added missing files 2015-01-18 15:29:12 +01:00
Simon Kusterer e834a679f5 reworked youtube plugin to support new architecute + added working basic example 2015-01-18 15:27:58 +01:00
Simon Kusterer e69cc01f6d added sample 'url' plugin 2015-01-18 13:39:49 +01:00
Simon Kusterer 89baea91c4 more mocking 2015-01-18 11:46:02 +01:00
Simon Kusterer 6842dbb87a some further mocking 2015-01-16 21:31:46 +01:00
Simon Kusterer 2a4c6908e2 change plugin goal 2015-01-15 23:40:22 +01:00
Simon Kusterer 9d4cf21f00 mocking together some structure 2015-01-15 23:01:14 +01:00
Simon Kusterer dde6795193 documented goals of v0.5 2015-01-15 20:32:51 +01:00
21 arquivos alterados com 1145 adições e 604 exclusões
+2
Ver Arquivo
@@ -2,3 +2,5 @@
node_modules/
.idea/
samples/
npm-debug.log
playground.js
+50 -83
Ver Arquivo
@@ -1,108 +1,75 @@
# castnow
# castnow v0.5
castnow is commandline utility which can be used to playback media files on
your chromecast device. It supports playback of local video files, youtube
clips, videos on the web and torrents. You can also re-attach a running
playback session.
In software it's often said that a codebase needs at least 2 rewrites until
it's considered as "good" code. I hope for castnow one rewrite will do it though :)
### usage
### Why the rewrite?
```
castnow has some pain points at the moment which I think
only can be solved nicely through an rewrite.
Here are some:
// start playback of a local video file
castnow ./myvideo.mp4
* Custom Plugins are not supported
* It would be hard to build a Web-Interface based on the current code
* The Playlist functionality is more or less a hack since it was not part of the initial idea/concept
* When FFmpeg Transcoding is done the player controls are not supported
* You can't mix for example YouTube Videos together with MP4 Videos in the Playlist
* There are often crashes while playing
* Transcoding always converts video+audio although often only transcoding of the audio-stream would be needed
// start playback of video and mp3 files in the local directory
castnow ./mydirectory/
// playback 3 videos after each other
castnow video1.mp4 video2.mp4 video3.mp4
### v0.5 goals
// start playback of some mp4 file over the web
castnow http://commondatastorage.googleapis.com/gtv-videos-bucket/ED_1280.mp4
#### Simplicity
// start playback of some youtube clip
castnow https://www.youtube.com/watch?v=pcVRrlmpcWk
Simplicity will stay the main focus. Someone should just need to type in `castnow <media source>`
into his terminal and castnow then should figure out by it self how to get the given media-input
running on Chromecast. Options like `--tomp4` or `--myip` should not be needed anymore.
// playback some youtube playlist
castnow https://www.youtube.com/playlist?list=PLrIJmi5XabBPNDJ_YyC-KNa_cZ6SwTOYC
#### Playlist first
// start playback of some video over torrent
castnow <url-to-torrent-file OR magnet>
Internally a Playlist will be the center of the architecture. The Playlist will communicate
with Chromecast through some kind of Engine thing. Items in the Playlist can be moved around and the User can jump forward and backward. The Playlist can contain items
of different types (for example youtube and some local mp4).
You may ask yourself why this Playlist thing is so important since the Playlist will
mostly contain just one item anyway. The answer is "Google Cast for Audio". With
a Playlist as center of the architecture it will be easy to support stuff like .m3u
files.
// start playback of some video over torrent, with local subtitles
castnow <url-to-torrent-file OR magnet> --subtitles </local/path/to/subtitles.srt>
#### More Hackable
// transcode some other videoformat to mp4 while playback (requires ffmpeg)
castnow ./myvideo.avi --tomp4
The goal here is that developers can pull in castnow as external library. For example someone could build an node-webkit (NW.js) app on top of castnow. Basicly this means the lib and bin stuff will get separated in the codebase.
// re-attach to an currently running playback session
castnow
#### User-Plugin support
```
If someone wants to write his own plugin without modifying the castnow codebase
he will be able todo that. The idea here is that the user can dynamicly
load plugins with some sort of `--plugin` parameter (e.g. `--plugin ./myplugin.js`).
### options
#### Better/Smarter transcoding
* `--tomp4` Transcode a video file to mp4 while playback. This option requires
ffmpeg to be installed on your computer. The play / pause controls are currently
not supported in transcode mode.
castnow will detect if ffmpeg or avconv is installed and if it has the minimum
required version. The plan is also to auto-detect if transcoding of an file is needed
using ffprobe. If only audio needs to be transcoded castnow will not transcode video.
Besides that the main goal is to support player controls for transcoding files (maybe
even seeking). We will also test if it's better to transcode to .mkv instead of .mp4.
* `--device "my chromecast"` If you have more than one chromecast in your network
use the `--device` option to specify the device on which you want to start casting.
Otherwise castnow will just use the first device it finds in the network.
#### Robustness
* `--address <IP>` The IP address of your chromecast. This will skip the MDNS scan.
This includes stuff like handling connection losses correctly, prevent the player
from going into idle modus if the player was paused too long and stuff like that.
* `--subtitles <path/URL>` This can be a path or URL to a vtt or srt file which
contains subtitles.
### What happens after v0.5.x
* `--myip <IP>` Your main IP address (useful if you have multiple network adapters)
The plan is to add an minimalstic web interface with an REST API. This likely
will happen within v0.6.x. After that, if nothing goes wrong, v1.0 is ahead :)
* `--verbose` Hide the player timeline.
### Contribution
* `--peerflix-* <val>` Pass options to peerflix.
* `--ffmpeg-* <val>` Pass options to ffmpeg.
* `--type <val>` Explicity set the mime-type of the first item in the playlist (e.g. 'video/mp4').
* `--seek <val>` Seek to the specified time on start using the format hh:mm:ss or mm:ss.
* `--bypass-srt-encoding` Disable automatic UTF8 encoding of SRT subtitles.
* `--help` Display help message.
### player controls
```
space // toggle between play and pause
m // toggle between mute and unmute
up // volume up
down // volume down
left // seek backward (keep pressed / multiple press for faster seek)
right // seek forward (keep pressed / multiple press for faster seek)
n // next item in the playlist (only supported in launch-mode)
s // stop playback
q // quit
```
### reporting bugs/issues
Please always append the debug output to your issues. You can enable the debug messages by setting the
DEBUG ENV variable before running the castnow-command like this: `DEBUG=castnow* castnow ./myvideo.mp4`.
Some problems are also already addressed in our wiki https://github.com/xat/castnow/wiki.
### installation
`npm install castnow -g`
### contributers
* [tooryx](https://github.com/tooryx)
* [przemyslawpluta](https://github.com/przemyslawpluta)
Contributers are welcome :-)
All I ask for is that we all agree on a similar coding style and have the same project goals in mind. I for myself love splitting up stuff in smaller functions and then compose
them together. I'm also willing to give direct push access to people who are constantly contributing.
## License
Copyright (c) 2014 Simon Kusterer
Copyright (c) 2015 Simon Kusterer
Licensed under the MIT license.
Arquivo executável
+67
Ver Arquivo
@@ -0,0 +1,67 @@
#!/usr/bin/env node
var castnow = require('./castnow')();
var opts = require('minimist')(process.argv.slice(2));
var attachMode = !opts._.length;
var launchMode = !attachMode;
var scanner = require('chromecast-scanner');
var debug = require('debug')('castnow:bin');
var engine = castnow.getEngine();
var pl = castnow.getPlaylist();
// plugins
var urlPlugin = require('./plugins/url');
var youtubePlugin = require('./plugins/youtube');
var youtubePlaylistPlugin = require('./plugins/youtubeplaylist');
var localfilePlugin = require('./plugins/localfile');
var directoriesPlugin = require('./plugins/directories');
var torrentPlugin = require('./plugins/torrent');
var abort = function(message) {
// stop
};
if (opts.help) {
// display help message
return;
}
if (opts.version) {
// display castnow version
return;
}
// Load Plugins here...
castnow.use(urlPlugin);
castnow.use(localfilePlugin);
castnow.use(youtubePlugin);
castnow.use(youtubePlaylistPlugin);
castnow.use(directoriesPlugin());
castnow.use(torrentPlugin);
if (opts.check) {
// - list all chromecast devices found in the network
// - check if ffmpeg is installed with an supported version
return;
}
if (launchMode) {
debug('launch mode');
castnow.resolve(opts._, function(err, items) {
if (err) return debug('error resolving items');
pl.append.apply(pl, items);
debug('appended items to playlist %s', items.length);
scanner(function(err, service) {
if (err) return debug('chromecast not found');
debug('found chromecast running with address %s', service.address);
castnow.connect(service.address, function(err) {
if (err) return debug('chromecast connection failed');
// load first item in playlist
pl.load(0, function(err, item, controls) {
if (err) return debug('load failed with error: %s', err.message);
debug('load succeeded');
});
});
});
});
}
+128
Ver Arquivo
@@ -0,0 +1,128 @@
var playlist = require('./lib/playlist');
var engine = require('./lib/engine');
var itemBuilder = require('./lib/itembuilder');
var utils = require('./lib/utils');
var xtend = require('xtend');
var isArray = require('util').isArray;
var express = require('express');
var async = require('async');
var hoook = require('hoook');
var debug = require('debug')('castnow');
var noop = function() {};
var defaults = {
port: 7373
};
var castnow = function(opts) {
var eng = engine();
var pl = playlist(eng);
var router = express.Router();
var uniqueId = utils.uniqueId(1);
var options = xtend(defaults, opts || {});
var app = express();
var flatteners = {};
var itemCreators = {};
var cn;
var flatten = function(input, cb) {
if (!cb) cb = noop;
// the flatten-hook can be used to extract
// the items of a playlist-like input (e.g. .m3u)
cn.fire('flatten', { input: input },
function(err, ev) {
if (err) return cb(err);
cb(null, isArray(ev.input) ? ev.input : [ev.input]);
}
);
};
var resolver = function(input, cb) {
if (!cb) cb = noop;
cn.fire('resolve', input, function(err, ev) {
if (err) return cb(err);
cb(null, ev.item || null);
});
};
app.use(router);
app.listen(options.port);
return cn = xtend({
getEngine: function() {
return eng;
},
getPlaylist: function() {
return pl;
},
// get an express router which
// can be used by all plugins.
getRouter: function() {
return router;
},
// connect to chromecast
connect: function() {
eng.connect.apply(null, arguments);
},
// resolve is used to transform some input
// into an item that can be added to the
// playlist
resolve: function(input, cb) {
if (!cb) cb = noop;
var that = this;
var inputs = isArray(input) ? input : [input];
async.concat(inputs, flatten, function(err, list) {
if (err) cb(err);
async.mapSeries(list, resolver, function(err, items) {
if (err) return cb(err);
items = items.filter(function(item) {
return !!item;
});
cb(null, items);
});
});
},
getOptions: function() {
return options;
},
createBlankItem: function() {
return itemBuilder(uniqueId());
},
createItem: function(name, options, cb) {
if (!itemCreators[name]) return cb(new Error('creator not found'));
itemCreators[name](options, cb);
},
flatten: function(name, options, cb) {
if (!flatteners[name]) return cb(new Error('flattener not found'));
flatteners[name](options, cb);
},
addItemCreator: function(name, fn) {
itemCreators[name] = fn;
},
addFlattener: function(name, fn) {
flattener[name] = fn;
},
// register a plugin
use: function(fn) {
fn(this);
return this;
}
}, hoook());
};
module.exports = castnow;
+64
Ver Arquivo
@@ -0,0 +1,64 @@
var castnow = require('../castnow')();
var scanner = require('chromecast-scanner');
var keypress = require('keypress');
var engine = castnow.getEngine();
var pl = castnow.getPlaylist();
// plugins
var urlPlugin = require('../plugins/url');
var torrentPlugin = require('../plugins/torrent');
var youtubePlugin = require('../plugins/youtube');
var youtubePlaylistPlugin = require('../plugins/youtubeplaylist');
// register plugins
castnow.use(urlPlugin);
castnow.use(youtubePlugin);
castnow.use(youtubePlaylistPlugin);
castnow.use(torrentPlugin);
var list = [
'https://www.youtube.com/watch?v=JskztPPSJwY',
'http://commondatastorage.googleapis.com/gtv-videos-bucket/ED_1280.mp4',
'https://www.youtube.com/watch?v=pcVRrlmpcWk',
'https://www.youtube.com/playlist?list=PLrIJmi5XabBPNDJ_YyC-KNa_cZ6SwTOYC'
];
castnow.resolve(list, function(err, items) {
if (err) return console.log('could not resolve items');
console.log('%s items resolved', items.length);
pl.append.apply(pl, items);
scanner(function(err, service) {
if (err) return console.log('chromecast not found');
castnow.connect(service.address, function(err) {
if (err) return console.log('chromecast connection failed');
keypress(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
pl.load();
console.log('use the arrow keys to jump back and forward in the playlist');
process.stdin.on('keypress', function(ch, key) {
if (key && key.name) {
if (key.name === 'left') {
return pl.prev(function(err) {
if (!err) console.log('jumped to prev item');
});
}
if (key.name === 'right') {
return pl.next(function(err) {
if (!err) console.log('jumped to next item');
});
}
}
if (key && key.ctrl && key.name === 'c') {
process.exit();
}
});
});
});
});
-304
Ver Arquivo
@@ -1,304 +0,0 @@
#!/usr/bin/env node
var player = require('chromecast-player')();
var opts = require('minimist')(process.argv.slice(2));
var chalk = require('chalk');
var keypress = require('keypress');
var ui = require('playerui')();
var circulate = require('array-loop');
var xtend = require('xtend');
var unformatTime = require('./utils/unformat-time');
var debug = require('debug')('castnow');
var debouncedSeeker = require('debounced-seeker');
var noop = function() {};
// plugins
var directories = require('./plugins/directories');
var localfile = require('./plugins/localfile');
var torrent = require('./plugins/torrent');
var youtubeplaylist = require('./plugins/youtubeplaylist');
var youtube = require('./plugins/youtube');
var transcode = require('./plugins/transcode');
var subtitles = require('./plugins/subtitles');
if (opts.help) {
return console.log([
'',
'Usage: castnow [<media>, <media>, ...] [OPTIONS]',
'',
'Option Meaning',
'--tomp4 Convert file to mp4 while playback',
'--device <name> The name of the chromecast device that should be used',
'--address <ip> The IP address of your chromecast device',
'--subtitles <path/url> Path or URL to an SRT or VTT file',
'--myip <ip> Your main IP address',
'--verbose No output',
'--peerflix-* <value> Pass options to peerflix',
'--ffmpeg-* <value> Pass options to ffmpeg',
'--type <val> Explicity set the mime-type (e.g. "video/mp4")',
'--bypass-srt-encoding Disable automatic UTF8 encoding of SRT subtitles',
'--seek <value> Seek to the specified time on start using the format hh:mm:ss or mm:ss',
'--help This help screen',
'',
'Player controls',
'',
'Key Meaning',
'space Toggle between play and pause',
'm Toggle between mute and unmute',
'up Volume Up',
'down Volume Down',
'left Seek backward',
'right Seek forward',
'n Next in playlist',
's Stop playback',
'quit Quit',
''
].join('\n'));
}
if (opts._.length) {
opts.playlist = opts._.map(function(item) {
return {
path: item
};
});
}
delete opts._;
if (opts.verbose || process.env.DEBUG) {
ui.hide();
}
ui.showLabels('state');
var last = function(fn, l) {
return function() {
var args = Array.prototype.slice.call(arguments);
args.push(l);
l = fn.apply(null, args);
return l;
};
};
var ctrl = function(err, p, ctx) {
if (err) {
debug('player error: %o', err);
console.log(chalk.red(err));
process.exit();
}
var playlist = ctx.options.playlist;
var volume;
keypress(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
// get initial volume
p.getVolume(function(err, status) {
volume = status;
});
if (!ctx.options.disableTimeline) {
p.on('position', function(pos) {
ui.setProgress(pos.percent);
ui.render();
});
}
var seek = debouncedSeeker(function(offset) {
if (ctx.options.disableSeek || offset === 0) return;
var seconds = Math.max(0, (p.getPosition() / 1000) + offset);
debug('seeking to %s', seconds);
p.seek(seconds);
}, 500);
var updateTitle = function() {
p.getStatus(function(err, status) {
if (!status.media ||
!status.media.metadata ||
!status.media.metadata.title) return;
var metadata = status.media.metadata;
var title;
if (metadata.artist) {
title = metadata.artist + ' - ' + metadata.title;
} else {
title = metadata.title;
}
ui.setLabel('source', 'Source', title);
ui.showLabels('state', 'source');
ui.render();
});
};
var initialSeek = function() {
var seconds = unformatTime(ctx.options.seek);
debug('seeking to %s', seconds);
p.seek(seconds);
};
p.on('playing', updateTitle);
if (!ctx.options.disableSeek && ctx.options.seek) {
p.once('playing', initialSeek);
}
updateTitle();
var nextInPlaylist = function() {
if (ctx.mode !== 'launch') return;
if (!playlist.length) return process.exit();
p.stop(function() {
ui.showLabels('state');
debug('loading next in playlist: %o', playlist[0]);
p.load(playlist[0], noop);
playlist.shift();
});
};
p.on('status', last(function(status, memo) {
if (status.playerState !== 'IDLE') return;
if (status.idleReason !== 'FINISHED') return;
if (memo && memo.playerState === 'IDLE') return;
nextInPlaylist();
return status;
}));
var keyMappings = {
// toggle between play / pause
space: function() {
if (p.currentSession.playerState === 'PLAYING') {
p.pause();
} else if (p.currentSession.playerState === 'PAUSED') {
p.play();
}
},
// toggle between mute / unmute
m: function() {
if (volume.muted) {
p.unmute(function(err, status) {
if (err) return;
volume = status;
});
} else {
p.mute(function(err, status) {
if (err) return;
volume = status;
});
}
},
// volume up
up: function() {
if (volume.level >= 1) return;
p.setVolume(Math.min(volume.level + 0.05, 1), function(err, status) {
if (err) return;
volume = status;
});
},
// volume down
down: function() {
if (volume.level <= 0) return;
p.setVolume(Math.max(volume.level - 0.05, 0), function(err, status) {
if (err) return;
volume = status;
});
},
// next item in playlist
n: function() {
nextInPlaylist();
},
// stop playback
s: function() {
p.stop();
},
// quit
q: function() {
process.exit();
},
// Rewind, one "seekCount" per press
left: function() {
seek(-30);
},
// Forward, one "seekCount" per press
right: function() {
seek(30);
}
};
process.stdin.on('keypress', function(ch, key) {
if (key && key.name && keyMappings[key.name]) {
debug('key pressed: %s', key.name);
keyMappings[key.name]();
}
if (key && key.ctrl && key.name == 'c') {
process.exit();
}
});
};
var capitalize = function(str) {
return str.substr(0, 1).toUpperCase() + str.substr(1);
};
var logState = (function() {
var inter;
var dots = circulate(['.', '..', '...', '....']);
return function(status) {
if (inter) clearInterval(inter);
debug('player status: %s', status);
inter = setInterval(function() {
ui.setLabel('state', 'State', capitalize(status) + dots());
ui.render();
}, 300);
};
})();
player.use(function(ctx, next) {
ctx.on('status', logState);
next();
});
player.use(directories);
player.use(torrent);
player.use(localfile);
player.use(youtubeplaylist);
player.use(youtube);
player.use(transcode);
player.use(subtitles);
player.use(function(ctx, next) {
if (ctx.mode !== 'launch') return next();
ctx.options = xtend(ctx.options, ctx.options.playlist[0]);
ctx.options.playlist.shift();
next();
});
if (!opts.playlist) {
debug('attaching...');
player.attach(opts, ctrl);
} else {
debug('launching...');
player.launch(opts, ctrl);
}
process.on('SIGINT', function() {
process.exit();
});
process.on('exit', function() {
ui.hide();
});
module.exports = player;
+218
Ver Arquivo
@@ -0,0 +1,218 @@
var hoook = require('hoook');
var xtend = require('xtend');
var Client = require('castv2-client').Client;
var async = require('async');
var noop = function() {};
var STATES = {
not_connected: 1,
connected: 2,
launched: 4
};
var engine = function(opts) {
var client = new Client();
var state = STATES.not_connected;
var loading = false;
var currentItem;
var currentControls;
// set the current item and controls
var setCurrent = function(item, controls) {
eng.fire('set_item', { item: item, controls: controls });
currentItem = item;
currentControls = controls;
};
// unset the current item and controls
var unsetCurrent = function() {
eng.fire('unset_item', { item: currentItem, controls: currentControls });
currentItem = null;
currentControls = null;
};
var setState = function(newState) {
eng.fire('state_change', { from: state, to: newState });
state = newState;
};
// close the client connection
// if an error occurs.
var onError = function() {
client.close();
};
// onClose get's called if the client
// looses connection to chromecast.
var onClose = function() {
setState(STATES.not_connected);
loading = false;
if (currentItem) {
currentItem.unload(currentControls, unsetCurrent);
} else {
unsetCurrent();
}
};
var onStatus = function(status) {
var apps = status.applications;
if (!apps || loading) return;
if (!currentItem) return;
if (currentItem.getAppId() !== apps[0].appId) {
// we will reach this point here if
// media playback was started with castnow
// and while playback the user suddenly casts
// something else with an other app.
setState(STATES.connected);
eng.fire('out_of_sync');
currentItem.unload(currentControls, unsetCurrent);
}
};
client.on('error', onError);
client.client.on('close', onClose);
client.on('status', onStatus);
var eng = xtend({
// connect to chromecast
connect: function(address, cb) {
if (!cb) cb = noop;
client.connect(address, function() {
setState(STATES.connected);
cb(null, client);
});
},
// close a connection
close: function() {
if (state === STATES.not_connected) return;
client.close();
},
// load an new item
load: function(item, attach, cb) {
if (arguments.length === 2) {
cb = attach;
attach = false;
}
if (!cb) cb = noop;
if (state === STATES.not_connected) {
return cb(new Error('not connected'));
}
if (loading) {
return cb(new Error('load already in progress'));
}
loading = true;
async.waterfall([
// unload previous item
function(next) {
if (!currentItem) return next();
currentItem.unload(currentControls, function(err) {
if (err) return next(err);
unsetCurrent();
next();
});
},
// get the currently running appId
function(next) {
client.getSessions(function(err, apps) {
if (err) return next(err);
if (!apps.length) return next(null, null);
next(null, apps[0]);
});
},
// launch or attach
function(session, next) {
if (!session || session.appId !== item.getAppId()) {
// launch
eng.fire('launch', { item: item }, function(err) {
if (err) return next(err);
client.launch(item.getApi(), next);
});
} else {
// attach
eng.fire('join', { item: item }, function(err) {
if (err) return next(err);
client.join(session, item.getApi(), next);
});
}
},
// load the item
function(controls, next) {
setState(STATES.launched);
setCurrent(item, controls);
if (attach) return next(null, controls);
eng.fire('load', { item: item, controls: controls }, function(err) {
if (err) return cb(err);
item.load(controls, function(err) {
if (err) return next(err);
next(null, controls);
});
});
}
], function(err, controls) {
loading = false;
if (err) return cb(err);
cb(null, item, controls);
});
},
// join a running playback session
join: function(item, cb) {
return this.load(item, true, cb);
},
// find running apps on chromecast
find: function(cb) {
if (!cb) cb = noop;
if (state === STATES.not_connected) {
return cb(new Error('not connected'));
}
client.getSessions(function(err, apps) {
if (err) return cb(err);
if (!apps.length) return cb(new Error('app not found'));
cb(null, apps[0]);
});
},
// get the current controlls
getControls: function() {
return currentControls;
},
// get the current loaded item
getItem: function() {
return currentItem;
},
hasItem: function() {
return !!currentItem;
},
getState: function() {
return state;
},
// if an item is currently getting loaded
isLoading: function() {
return loading;
}
}, hoook());
return eng;
};
engine.STATES = STATES;
module.exports = engine;
+67
Ver Arquivo
@@ -0,0 +1,67 @@
var player = require('chromecast-player');
var itemBuilder = function(id) {
var data = {};
var api = player.api;
var appId = api.APP_ID;
var args = {};
return {
getId: function() {
return id;
},
setApi: function(a) {
api = a;
},
getApi: function() {
return api;
},
setAppId: function(id) {
appId = id;
},
getAppId: function() {
return appId;
},
setArgs: function(a) {
args = a;
},
getArgs: function() {
return args;
},
setSource: function(src) {
source = src;
},
getSource: function() {
return source;
},
set: function(key, val) {
data[key] = val;
},
get: function(key) {
return data[key];
},
load: function(controls, cb) {
controls.load(this.getArgs(), cb);
},
unload: function(controls, cb) {
if (controls.destroy) controls.destroy();
cb();
}
}
};
module.exports = itemBuilder;
+109
Ver Arquivo
@@ -0,0 +1,109 @@
var hoook = require('hoook');
var xtend = require('xtend');
var utils = require('./utils');
var find = require('array-find');
var noop = function() {};
var ENGINE_STATES = require('./engine').STATES;
var STATES = {
not_loaded: 1,
loaded: 2
};
var playlist = function(engine) {
var items = [];
var uniqueId = utils.uniqueId(1);
var cursor;
var pl;
return pl = xtend({
load: function(pos, cb) {
if (!cb) cb = noop;
if (typeof pos === 'undefined') pos = 0;
if (typeof items[pos] === 'undefined') {
return cb(new Error('out of range'));
}
if (engine.getState() === ENGINE_STATES.not_connected) {
return cb(new Error('engine not connected'));
}
engine.load(items[pos], function(err, controls, item) {
if (err) return cb(err);
cursor = pos;
cb(null, controls, item);
});
},
append: function() {
items.push.apply(items, arguments);
},
prepend: function() {
items.unshift.apply(items, arguments);
},
move: function(from, to) {
},
remove: function(pos) {
},
getCurrent: function() {
if (typeof cursor === 'undefined') return false;
return items[cursor];
},
count: function() {
return items.length;
},
// load the next item
next: function(cb) {
if (!cb) cb = noop;
if (!items.length) return cb(new Error('playlist is empty'));
if (typeof cursor === 'undefined') {
// load first
return this.load(0, cb);
}
if (this.hasNext()) {
return this.load(cursor+1, cb);
}
cb(new Error('next item does not exist'));
},
// load the prev item
prev: function(cb) {
if (!cb) cb = noop;
if (!items.length) return cb(new Error('playlist is empty'));
if (this.hasPrev()) {
return this.load(cursor-1, cb);
}
cb(new Error('prev item does not exist'));
},
hasNext: function() {
if (typeof cursor === 'undefined') return false;
return (cursor+1) < items.length;
},
hasPrev: function() {
if (typeof cursor === 'undefined') return false;
return cursor > 0;
},
// find an item by its id
findItem: function(id) {
return find(items, function(item) {
return item.getId() === id;
});
}
}, hoook());
};
playlist.STATES = STATES;
module.exports = playlist;
+80
Ver Arquivo
@@ -0,0 +1,80 @@
var os = require('os');
// change a time-string in the format 'xx:xx:xx'
// or 'xx:xx' to seconds
var unformatTime = function(str) {
return str.split(':')
.reverse()
.map(function(val, i) {
return parseInt(val, 10) * Math.pow(60, i);
})
.reduce(function(a, b) {
return a + b;
}, 0);
};
// create unique ids
var uniqueId = function(start) {
var c = start || 0;
return function() {
return c++;
};
};
// basic key/val store
var store = function() {
var memo = {};
return {
get: function(key) {
return memo[key];
},
set: function(key, val) {
memo[key] = val;
}
};
};
// count how many characters in two strings
// overlap, starting from the beginning
// and ending as soon there is a non-matching
// character
var overlapCount = function(str1, str2) {
var len = Math.min(str1.length, str2.length);
var i = 0;
for (; i<len; i++) {
if (str1[i] !== str2[i]) return i;
}
return len;
};
// get the local ip which matches
// the most with the given compare-ip
// Notice: since we don't know the subnetmask
// of our local network adapters overlap
// checking is better than nothing.
var guessIp = function(compare) {
var interfaces = os.networkInterfaces();
var memo = [];
if (!compare) compare = '';
Object.keys(interfaces).forEach(function(i) {
interfaces[i].forEach(function(i2) {
if (!i2.internal && i2.family === 'IPv4') {
i2.overlap = overlapCount(i2.address, compare);
memo.push(i2);
}
});
});
return memo
.reduce(function(a, b) {
return a.overlap > b.overlap ? a : b;
}).address;
};
module.exports = {
unformatTime: unformatTime,
uniqueId: uniqueId,
store: store,
guessIp: guessIp
};
+20 -19
Ver Arquivo
@@ -1,13 +1,13 @@
{
"name": "castnow",
"version": "0.4.7",
"version": "0.5.0",
"description": "commandline chromecast player",
"main": "index.js",
"main": "castnow.js",
"bin": {
"castnow": "./index.js"
"castnow": "./bin.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node test.js"
},
"author": "Simon Kusterer",
"license": "MIT",
@@ -27,28 +27,29 @@
"cast"
],
"dependencies": {
"array-loop": "^1.0.0",
"array-find": "^0.1.1",
"async": "^0.9.0",
"castv2-client": "0.0.8",
"chalk": "^0.5.1",
"chromecast-player": "0.1.7",
"debounced-seeker": "^1.0.0",
"debug": "^2.1.0",
"fs-extended": "^0.2.0",
"chromecast-player": "^0.1.7",
"chromecast-scanner": "^0.1.0",
"debug": "^2.1.1",
"express": "^4.11.0",
"get-youtube-id": "^0.1.3",
"got": "^1.2.2",
"got": "^2.3.0",
"hoook": "^0.1.0",
"internal-ip": "^1.0.0",
"is-url": "^1.2.0",
"keypress": "^0.2.1",
"mime": "^1.2.11",
"minimist": "^1.1.0",
"peerflix": "^0.19.1",
"playerui": "^1.2.0",
"query-string": "^1.0.0",
"pump": "^1.0.0",
"query-extend": "^0.1.4",
"range-parser": "^1.0.2",
"read-torrent": "^1.0.0",
"router": "^0.6.2",
"srt2vtt": "^1.2.0",
"stream-transcoder": "0.0.5",
"read-torrent": "^1.2.0",
"torrent-stream": "^0.16.2",
"xml2js": "^0.4.4",
"xtend": "^4.0.0"
},
"devDependencies": {
"tape": "^3.4.0"
}
}
+25 -35
Ver Arquivo
@@ -1,48 +1,38 @@
var fs = require('fs-extended');
var fs = require('fs');
var path = require('path');
var xtend = require('xtend');
var join = path.join;
var extname = path.extname;
var debug = require('debug')('castnow:directories');
var acceptedExtensions = {
'.mp3': true,
'.mp4': true
var defaults = {
extensions: ['.mp3', '.mp4']
};
function filter(filePath) {
return !!acceptedExtensions[extname(filePath)];
}
var isDir = function(item) {
return fs.existsSync(item.path) && fs.lstatSync(item.path).isDirectory();
var isDir = function(path) {
return fs.existsSync(path) && fs.lstatSync(path).isDirectory();
};
// check which items in the playlist are
// actually directories and get all mp4 and
// mp3 files out of those.
var flattenFiles = function(playlist) {
var items = [];
playlist.forEach(function(item) {
if (isDir(item)) {
debug('directory found: %s', item.path);
var mediaFiles = fs.listFilesSync(item.path, { filter: filter });
items.push.apply(items, mediaFiles.map(function(file) {
debug('added file %s from directory %s', file, item.path);
return {
path: join(item.path, file)
};
}));
return;
}
items.push(item);
});
return items;
};
var directories = function(opts) {
var options = xtend(defaults, opts || {});
var directories = function(ctx, next) {
if (ctx.mode !== 'launch') return next();
ctx.options.playlist = flattenFiles(ctx.options.playlist);
next();
var filterByExt = function(filePath) {
return options.extensions.indexOf(extname(filePath).toLowerCase()) > -1;
};
return function(castnow) {
castnow.hook('flatten', function(ev, next, stop) {
if (!isDir(ev.input)) return next();
debug('directory found: %s', ev.input);
var files = fs.readdirSync(ev.input)
.filter(filterByExt)
.map(function(filename) {
return join(ev.input, filename);
});
ev.input = files;
stop();
});
};
};
module.exports = directories;
+49 -33
Ver Arquivo
@@ -1,53 +1,69 @@
var http = require('http');
var internalIp = require('internal-ip');
var router = require('router');
var path = require('path');
var serveMp4 = require('../utils/serve-mp4');
var serveMp4 = require('../lib/serve-mp4');
var debug = require('debug')('castnow:localfile');
var fs = require('fs');
var port = 4100;
var isFile = function(item) {
return fs.existsSync(item.path) && fs.statSync(item.path).isFile();
var isFile = function(path) {
return fs.existsSync(path) && fs.statSync(path).isFile();
};
var contains = function(arr, cb) {
for (var i=0, len=arr.length; i<len; i++) {
if (cb(arr[i], i)) return true;
}
return false;
};
var localfile = function(castnow) {
var options = castnow.getOptions();
var router = castnow.getRouter();
var playlist = castnow.getPlaylist();
var ip = options.ip || internalIp();
var localfile = function(ctx, next) {
if (ctx.mode !== 'launch') return next();
if (!contains(ctx.options.playlist, isFile)) return next();
router.get('/localfile/:id', function(req, res) {
var item = playlist.findItem(parseInt(req.params.id, 10));
if (!item) return res.sendStatus(404);
debug('incoming request serving %s', item.getSource());
serveMp4(req, res, item.getSource());
});
var route = router();
var list = ctx.options.playlist.slice(0);
var ip = (ctx.options.myip || internalIp());
castnow.addItemCreator('localfile', function(o, cb) {
var opts = {};
var item;
var url;
ctx.options.playlist = list.map(function(item, idx) {
if (!isFile(item)) return item;
return {
path: 'http://' + ip + ':' + port + '/' + idx,
type: 'video/mp4',
if (typeof o === 'string') {
opts.source = o;
} else {
opts = o;
}
if (!isFile(opts.source)) return cb(new Error('not a valid file'));
item = castnow.createBlankItem();
item.setSource(opts.source);
url = 'http://' + ip + ':' + options.port + '/localfile/' + item.getId();
item.setArgs({
autoplay: true,
currentTime: opts.start || 0,
media: {
contentId: url,
contentType: 'video/mp4',
streamType: 'BUFFERED',
metadata: {
title: path.basename(item.path)
title: path.basename(item.getSource())
}
}
};
});
});
route.all('/{idx}', function(req, res) {
debug('incoming request serving %s', list[req.params.idx].path);
serveMp4(req, res, list[req.params.idx].path);
});
http.createServer(route).listen(port);
debug('started webserver on address %s using port %s', ip, port);
next();
castnow.hook('resolve', function(ev, next, stop) {
var input = ev.input;
if (!isFile(input.source)) return next();
debug('localfile detected: %s', source);
castnow.createItem('localfile', input, function(err, item) {
if (err) return stop(err);
ev.item = item;
return stop();
});
}, 600);
};
module.exports = localfile;
+92 -30
Ver Arquivo
@@ -1,41 +1,103 @@
var readTorrent = require('read-torrent');
var peerflix = require('peerflix');
var torrentStream = require('torrent-stream');
var internalIp = require('internal-ip');
var grabOpts = require('../utils/grab-opts');
var debug = require('debug')('castnow:torrent');
var port = 4102;
var pump = require('pump');
var rangeParser = require('range-parser');
var mime = require('mime');
var torrent = function(ctx, next) {
if (ctx.mode !== 'launch') return next();
if (ctx.options.playlist.length > 1) return next();
var path = ctx.options.playlist[0].path;
// the code here is mostly copied together from peerflix.
// all credits go to mafintosh.
if (!/^magnet:/.test(path) &&
!/torrent$/.test(path) &&
!ctx.options.torrent) return next();
var isTorrent = function(path) {
return /^magnet:/.test(path) || /torrent$/.test(path);
};
readTorrent(path, function(err, torrent) {
if (err) {
debug('error reading torrent: %o', err);
return next();
var torrent = function(castnow) {
var router = castnow.getRouter();
var playlist = castnow.getPlaylist();
var ip = castnow.getOptions().ip || internalIp();
var port = castnow.getOptions().port;
router.get('/torrent/:id', function(req, res) {
var item = playlist.findItem(parseInt(req.params.id, 10));
if (!item || !item.get('torrent')) return res.sendStatus(404);
var file = item.get('file');
var range = req.headers.range;
var len = file.length;
res.setHeader('Content-Type', mime.lookup(file.name));
res.setHeader('Access-Control-Allow-Origin', '*');
if (!range) {
res.setHeader('Content-Length', len);
res.statusCode = 200;
return pump(file.createReadStream(), res);
}
if (!ctx.options['peerflix-port']) ctx.options['peerflix-port'] = port;
var engine = peerflix(torrent, grabOpts(ctx.options, 'peerflix-'));
var ip = ctx.options.myip || internalIp();
engine.server.once('listening', function() {
debug('started webserver on address %s using port %s', ip, engine.server.address().port);
ctx.options.playlist[0] = {
path: 'http://' + ip + ':' + engine.server.address().port,
type: 'video/mp4',
media: {
metadata: {
title: engine.server.index.name
}
}
};
next();
});
var part = rangeParser(len, range)[0];
var chunksize = (part.end - part.start) + 1;
res.setHeader('Content-Range', 'bytes ' + part.start + '-' + part.end + '/' + len);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', chunksize);
res.statusCode = 206;
return pump(file.createReadStream(part), res);
});
castnow.hook('resolve', function(ev, next, stop) {
var item = ev.item;
if (!isTorrent(item.getSource())) return next();
debug('torrent detected: %s', item.getSource());
readTorrent(item.getSource(), function(err, torr) {
if (err) {
debug('error reading torrent: %o', err);
return stop(new Error('error reading torrent'));
}
var url = 'http://' + ip + ':' + port + '/torrent/' + item.getId();
item.set('torrent', torr);
item.setArgs({
autoplay: true,
currentTime: 0,
media: {
contentId: url,
contentType: 'video/mp4',
streamType: 'BUFFERED'
}
});
item.enable();
stop();
});
}, 800);
// TODO: Clean up stuff, once torrent is finished.
playlist.hook('load', function(ev, next) {
var item = ev.item;
if (!item.get('torrent')) return next();
var torrentEngine = torrentStream(item.get('torrent'));
var onReady = function() {
// extract largest file
// (this will likely be the video file)
var file = torrentEngine.files.reduce(function(a, b) {
return a.length > b.length ? a : b;
});
file.select();
item.set('file', file);
next();
};
if (torrentEngine.torrent) {
onReady();
} else {
torrentEngine.once('ready', onReady);
}
});
};
module.exports = torrent;
+50
Ver Arquivo
@@ -0,0 +1,50 @@
var isUrl = require('is-url');
var debug = require('debug')('castnow:url');
var url = function(castnow) {
castnow.addItemCreator('url', function(o, cb) {
var opts = {};
var item;
var url;
if (typeof o === 'string') {
opts.source = o;
} else {
opts = o;
}
if (!isUrl(opts.source)) return cb(new Error('not a valid url'));
item = castnow.createBlankItem();
item.setSource(opts.source);
item.setArgs({
autoplay: true,
currentTime: opts.start || 0,
media: {
contentId: opts.source,
contentType: 'video/mp4',
streamType: 'BUFFERED'
}
});
return cb(null, item);
});
castnow.hook('resolve', function(ev, done, stop) {
var input = ev.input;
if (!isUrl(item.source)) return done();
debug('url detected', item.source);
castnow.createItem('url', input, function(err, item) {
if (err) return stop(err);
ev.item = item;
return stop();
});
}, 500);
};
module.exports = url;
+23 -16
Ver Arquivo
@@ -16,25 +16,32 @@ Yt.APP_ID = '233637DE';
inherits(Yt, Api);
Yt.prototype.load = function(options, cb) {
var youtubeId = getYouTubeId(options.path);
debug('loading video with id %s', youtubeId);
var opts = {
type: 'flingVideo',
data: {
currentTime: 0,
videoId: youtubeId
}
};
this.ytreq.request(opts);
this.ytreq.request(options);
if (cb) cb();
};
var youtube = function(ctx, next) {
if (ctx.mode !== 'launch') return next();
if (!getYouTubeId(ctx.options.playlist[0].path)) return next();
debug('using youtube api');
ctx.api = Yt;
next();
var youtube = function(castnow) {
castnow.hook('resolve', function(ev, next, stop) {
var item = ev.item;
var youtubeId = getYouTubeId(item.getSource());
if (!youtubeId) return next();
debug('youtube url detected %s', item.getSource());
item.setApi('youtube', Yt);
item.setArgs({
type: 'flingVideo',
data: {
currentTime: 0,
videoId: youtubeId
}
});
item.enable();
stop();
}, 1000);
};
module.exports = youtube;
+31 -55
Ver Arquivo
@@ -1,72 +1,48 @@
var url = require('url');
var got = require('got');
var qs = require('query-string');
var parser = require('xml2js').parseString;
var debug = require('debug')('castnow:youtubeplaylist');
var queryExtend = require('query-extend');
function getPlaylistItems(id, callback) {
var getYoutubeItemUrl = function(id) {
return 'https://www.youtube.com/watch?v=' + id;
};
got('https://gdata.youtube.com/feeds/api/playlists/' + id + '?v=2&max-results=50', function get(err, data, res) {
var getApiUrl = function(id) {
return 'https://gdata.youtube.com/feeds/api/playlists/' + id + '?v=2&max-results=50';
};
var videos = [];
var isYoutubePlaylist = function(input) {
return /youtube/.test(input) && /playlist\?list/.test(input);
};
if (!err && res.statusCode === 200) {
return parser(data, { normalizeTags: true, explicitArray: true }, function parse(err, result) {
var i;
for (i = 0; i < result.feed.entry.length; i++) {
videos.push({path: 'https://www.youtube.com/watch?v=' + result.feed.entry[i]['media:group'][0]['yt:videoid']});
}
callback(videos);
var getListId = function(url) {
return queryExtend(url, true).list;
};
var getPlaylistItems = function(id, cb) {
got(getApiUrl(id), function get(err, data, res) {
if (err || res.statusCode !== 200) return cb(null, [])
parser(data, { normalizeTags: true, explicitArray: true }, function(err, result) {
if (err) return cb(null, []);
var videos = result.feed.entry.map(function(entry) {
return getYoutubeItemUrl(entry['media:group'][0]['yt:videoid']);
});
}
if (err) { console.log(err.stack); }
callback(videos);
cb(null, videos);
});
});
};
}
var youtubePlaylist = function(castnow) {
function updatePlaylist(stash, ctx, next) {
var out = [], i;
castnow.hook('flatten', function(ev, next, stop) {
var input = ev.input;
if (!isYoutubePlaylist(input)) return next();
for (i = 0; i < ctx.options.playlist.length; i++) {
if (!stash[ctx.options.playlist[i]]) {
out.push(ctx.options.playlist[i]);
} else {
out = out.concat(stash[ctx.options.playlist[i]]);
}
}
ctx.options.playlist = out;
next();
}
debug('youtube playlist detected %s', input);
var youtubePlaylist = function youtubePlaylist(ctx, next) {
if (ctx.mode !== 'launch') return next();
var items = [], stash = {}, count = 0, i;
for (i = 0; i < ctx.options.playlist.length; i++) {
if (/youtube/.test(ctx.options.playlist[i].path) && /playlist\?list/.test(ctx.options.playlist[i].path)) {
debug('loading youtube playlist %s', ctx.options.playlist[i].path);
items.push(qs.parse(url.parse(ctx.options.playlist[i].path).query).list);
ctx.options.playlist[i] = items.length;
}
}
if (!items.length) return next();
items.forEach(function grabDetails(item) {
getPlaylistItems(item, function get(found) {
count = count + 1;
stash[count] = found;
if (count === items.length) { updatePlaylist(stash, ctx, next); }
getPlaylistItems(getListId(input), function(err, videos) {
ev.input = videos;
stop();
});
});
+70
Ver Arquivo
@@ -0,0 +1,70 @@
var test = require('tape');
var castnow = require('./castnow')();
var ENGINE_STATES = require('./lib/engine').STATES;
var scanner = require('chromecast-scanner');
var urlPlugin = require('./plugins/url');
var async = require('async');
var demo = 'http://commondatastorage.googleapis.com/gtv-videos-bucket/ED_1280.mp4';
castnow.use(urlPlugin);
test('castnow engine', function(t) {
var engine = castnow.getEngine();
async.waterfall([
function(next) {
scanner(function(err, service) {
if (err) return next(err);
return next(null, service.address);
});
},
function(address, next) {
t.equal(engine.getState(), ENGINE_STATES.not_connected, 'engine should not be connected');
engine.connect(address, function() {
t.equal(engine.getState(), ENGINE_STATES.connected, 'engine should be connected');
next();
});
},
function(next) {
castnow.createItem('url', demo, function(err, item) {
next(null, item);
});
},
function(item, next) {
engine.load(item, function(err) {
t.equal(err, null, 'there should not be any load error');
t.equal(engine.getState(), ENGINE_STATES.launched, 'engine should be in launch state')
next(null, item);
});
},
function(item, next) {
setTimeout(function() {
engine.load(item, function() {
next();
});
}, 5000);
},
function(next) {
setTimeout(function() {
engine.close();
setTimeout(function() {
t.equal(engine.getState(), ENGINE_STATES.not_connected, 'engine should not be connected');
next();
}, 1000);
}, 5000);
}
],
function(err) {
if (err) {
t.fail(err.message);
}
t.end();
});
});
-10
Ver Arquivo
@@ -1,10 +0,0 @@
module.exports = function(options, prefix) {
var opts = {};
var len = prefix.length;
for (var key in options) {
if (key.substr(0, len) === prefix) {
opts[key.substr(len)] = options[key];
}
}
return opts;
};
-19
Ver Arquivo
@@ -1,19 +0,0 @@
module.exports = function (string) {
var timeArray = string.split(':'),
seconds = 0;
// turn hours and minutes into seconds and add them all up
if (timeArray.length === 3) {
// hours
seconds = seconds + (parseInt(timeArray[0]) * 60 * 60);
// minutes
seconds = seconds + (parseInt(timeArray[1]) * 60);
// seconds
seconds = seconds + parseInt(timeArray[2]);
} else if (timeArray.length === 2) {
// minutes
seconds = seconds + (parseInt(timeArray[0]) * 60);
// seconds
seconds = seconds + parseInt(timeArray[1]);
}
return seconds;
};