// The Cast module talks to Airplay and Chromecast // * Modifies state when things change // * Starts and stops casting, provides remote video controls module.exports = { init, toggleMenu, selectDevice, stop, play, pause, seek, setVolume, setRate } const config = require('../../config') const {CastingError} = require('./errors') // Lazy load these for a ~300ms improvement in startup time let airplayer, chromecasts, dlnacasts // App state. Cast modifies state.playing and state.errors in response to events let state // Callback to notify module users when state has changed let update // setInterval() for updating cast status let statusInterval = null // Start looking for cast devices on the local network function init (appState, callback) { state = appState update = callback // Don't actually cast during integration tests // (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.) if (config.IS_TEST) { state.devices.chromecast = testPlayer('chromecast') state.devices.airplay = testPlayer('airplay') state.devices.dlna = testPlayer('dlna') return } // Load modules, scan the network for devices airplayer = require('airplayer')() chromecasts = require('chromecasts')() dlnacasts = require('dlnacasts')() state.devices.chromecast = chromecastPlayer() state.devices.dlna = dlnaPlayer() state.devices.airplay = airplayPlayer() // Listen for devices: Chromecast, DLNA and Airplay chromecasts.on('update', function (device) { // TODO: how do we tell if there are *no longer* any Chromecasts available? // From looking at the code, chromecasts.players only grows, never shrinks state.devices.chromecast.addDevice(device) }) dlnacasts.on('update', function (device) { state.devices.dlna.addDevice(device) }) airplayer.on('update', function (device) { state.devices.airplay.addDevice(device) }) } // integration test player implementation function testPlayer (type) { return { getDevices, open, play, pause, stop, status, seek, volume } function getDevices () { return [{name: type + '-1'}, {name: type + '-2'}] } function open () {} function play () {} function pause () {} function stop () {} function status () {} function seek () {} function volume () {} } // chromecast player implementation function chromecastPlayer () { const ret = { device: null, addDevice, getDevices, open, play, pause, stop, status, seek, volume } return ret function getDevices () { return chromecasts.players } function addDevice (device) { device.on('error', function (err) { if (device !== ret.device) return state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to Chromecast. ' + err.message }) update() }) device.on('disconnect', function () { if (device !== ret.device) return state.playing.location = 'local' update() }) } function open () { const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, { type: 'video/mp4', title: config.APP_NAME + ' - ' + torrentSummary.name }, function (err) { if (err) { state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to Chromecast. ' + err.message }) } else { state.playing.location = 'chromecast' } update() }) } function play (callback) { ret.device.play(null, null, callback) } function pause (callback) { ret.device.pause(callback) } function stop (callback) { ret.device.stop(callback) } function status () { ret.device.status(handleStatus) } function seek (time, callback) { ret.device.seek(time, callback) } function volume (volume, callback) { ret.device.volume(volume, callback) } } // airplay player implementation function airplayPlayer () { const ret = { device: null, addDevice, getDevices, open, play, pause, stop, status, seek, volume } return ret function addDevice (player) { player.on('event', function (event) { switch (event.state) { case 'loading': break case 'playing': state.playing.isPaused = false break case 'paused': state.playing.isPaused = true break case 'stopped': break } update() }) } function getDevices () { return airplayer.players } function open () { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, function (err, res) { if (err) { state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to AirPlay. ' + err.message }) } else { state.playing.location = 'airplay' } update() }) } function play (callback) { ret.device.resume(callback) } function pause (callback) { ret.device.pause(callback) } function stop (callback) { ret.device.stop(callback) } function status () { ret.device.playbackInfo(function (err, res, status) { if (err) { state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to AirPlay. ' + err.message }) } else { state.playing.isPaused = status.rate === 0 state.playing.currentTime = status.position update() } }) } function seek (time, callback) { ret.device.scrub(time, callback) } function volume (volume, callback) { // AirPlay doesn't support volume // TODO: We should just disable the volume slider state.playing.volume = volume } } // DLNA player implementation function dlnaPlayer (player) { const ret = { device: null, addDevice, getDevices, open, play, pause, stop, status, seek, volume } return ret function getDevices () { return dlnacasts.players } function addDevice (device) { device.on('error', function (err) { if (device !== ret.device) return state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to DLNA. ' + err.message }) update() }) device.on('disconnect', function () { if (device !== ret.device) return state.playing.location = 'local' update() }) } function open () { const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, { type: 'video/mp4', title: config.APP_NAME + ' - ' + torrentSummary.name, seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0 }, function (err) { if (err) { state.playing.location = 'local' state.errors.push({ time: new Date().getTime(), message: 'Could not connect to DLNA. ' + err.message }) } else { state.playing.location = 'dlna' } update() }) } function play (callback) { ret.device.play(null, null, callback) } function pause (callback) { ret.device.pause(callback) } function stop (callback) { ret.device.stop(callback) } function status () { ret.device.status(handleStatus) } function seek (time, callback) { ret.device.seek(time, callback) } function volume (volume, callback) { ret.device.volume(volume, function (err) { // quick volume update state.playing.volume = volume callback(err) }) } } function handleStatus (err, status) { if (err || !status) { return console.log('error getting %s status: %o', state.playing.location, err || 'missing response') } state.playing.isPaused = status.playerState === 'PAUSED' state.playing.currentTime = status.currentTime state.playing.volume = status.volume.muted ? 0 : status.volume.level update() } // Start polling cast device state, whenever we're connected function startStatusInterval () { statusInterval = setInterval(function () { const player = getPlayer() if (player) player.status() }, 1000) } /* * Shows the device menu for a given cast type ('chromecast', 'airplay', etc) * The menu lists eg. all Chromecasts detected; the user can click one to cast. * If the menu was already showing for that type, hides the menu. */ function toggleMenu (location) { // If the menu is already showing, hide it if (state.devices.castMenu && state.devices.castMenu.location === location) { state.devices.castMenu = null return } // Never cast to two devices at the same time if (state.playing.location !== 'local') { throw new CastingError( `You can't connect to ${location} when already connected to another device` ) } // Find all cast devices of the given type const player = getPlayer(location) const devices = player ? player.getDevices() : [] if (devices.length === 0) { throw new CastingError(`No ${location} devices available`) } // Show a menu state.devices.castMenu = {location, devices} } function selectDevice (index) { const {location, devices} = state.devices.castMenu // Start casting const player = getPlayer(location) player.device = devices[index] player.open() // Poll the casting device's status every few seconds startStatusInterval() // Show the Connecting... screen state.devices.castMenu = null state.playing.castName = devices[index].name state.playing.location = location + '-pending' update() } // Stops casting, move video back to local screen function stop () { const player = getPlayer() if (player) { player.stop(function () { player.device = null stoppedCasting() }) clearInterval(statusInterval) } else { stoppedCasting() } } function stoppedCasting () { state.playing.location = 'local' state.playing.jumpToTime = Number.isFinite(state.playing.currentTime) ? state.playing.currentTime : 0 update() } function getPlayer (location) { if (location) { return state.devices[location] } else if (state.playing.location === 'chromecast') { return state.devices.chromecast } else if (state.playing.location === 'airplay') { return state.devices.airplay } else if (state.playing.location === 'dlna') { return state.devices.dlna } else { return null } } function play () { const player = getPlayer() if (player) player.play(castCallback) } function pause () { const player = getPlayer() if (player) player.pause(castCallback) } function setRate (rate) { let player let result = true if (state.playing.location === 'chromecast') { // TODO find how to control playback rate on chromecast castCallback() result = false } else if (state.playing.location === 'airplay') { player = state.devices.airplay player.rate(rate, castCallback) } else { result = false } return result } function seek (time) { const player = getPlayer() if (player) player.seek(time, castCallback) } function setVolume (volume) { const player = getPlayer() if (player) player.volume(volume, castCallback) } function castCallback () { console.log('%s callback: %o', state.playing.location, arguments) }