diff --git a/renderer/index.css b/renderer/index.css index 4f95be6c..11dac2ed 100644 --- a/renderer/index.css +++ b/renderer/index.css @@ -643,9 +643,10 @@ body.drag .torrent-placeholder span { .player-controls .play-pause { display: block; - width: 20px; - height: 20px; - margin: 5px auto; + width: 30px; + height: 30px; + padding: 5px; + margin: 0 auto; } .player-controls .chromecast, @@ -674,6 +675,11 @@ body.drag .torrent-placeholder span { margin-top: 8px !important; } +.player-controls .chromecast.active, +.player-controls .airplay.active { + color: #9af; +} + .player .playback-bar:hover .loading-bar { height: 5px; } @@ -686,6 +692,33 @@ body.drag .torrent-placeholder span { margin-left: 0; } +/* + * CHROMECAST / AIRPLAY CONTROLS + */ +.cast-screen { + width: 400px; + margin: auto; + color: #eee; + line-height: normal; +} + +.cast-screen h1 { + font-size: 3em; +} + +.cast-screen .cast-status { + margin: 40px 0; + font-size: 2em; +} + +.cast-screen .stop-casting { + cursor: pointer; +} + +.cast-screen .stop-casting:hover { + color: #9af; +} + /* * MEDIA QUERIES */ diff --git a/renderer/index.js b/renderer/index.js index 1c633a06..755a8a58 100644 --- a/renderer/index.js +++ b/renderer/index.js @@ -1,9 +1,7 @@ console.time('init') -var airplay = require('airplay-js') var cfg = require('application-config')('WebTorrent') var cfgDirectory = require('application-config-path')('WebTorrent') -var chromecasts = require('chromecasts')() var createTorrent = require('create-torrent') var dragDrop = require('drag-drop') var electron = require('electron') @@ -22,6 +20,7 @@ var App = require('./views/app') var config = require('../config') var torrentPoster = require('./lib/torrent-poster') var TorrentPlayer = require('./lib/torrent-player') +var Cast = require('./lib/cast') // Electron apps have two processes: a main process (node) runs first and starts // a renderer process (essentially a Chrome window). We're in the renderer process, @@ -86,7 +85,7 @@ function init () { // OS integrations: // ...Chromecast and Airplay - detectDevices() + Cast.init(update) // ...drag and drop a torrent or video file to play or seed dragDrop('body', onFiles) @@ -188,10 +187,13 @@ function dispatch (action, ...args) { toggleSelectTorrent(args[0] /* infoHash */) } if (action === 'openChromecast') { - openChromecast() + Cast.openChromecast() } if (action === 'openAirplay') { - openAirplay() + Cast.openAirplay() + } + if (action === 'stopCasting') { + Cast.stopCasting() } if (action === 'setDimensions') { setDimensions(args[0] /* dimensions */) @@ -206,12 +208,11 @@ function dispatch (action, ...args) { // TODO // window.history.forward() } - if (action === 'pause') { - if (state.url !== 'player' || state.video.isPaused) { - ipcRenderer.send('paused-video') - } - state.video.isPaused = true - update() + if (action === 'playPause') { + playPause() + } + if (action === 'playbackJump') { + jumpToTime(args[0] /* seconds */) } if (action === 'videoPlaying') { ipcRenderer.send('blockPowerSave') @@ -220,14 +221,6 @@ function dispatch (action, ...args) { ipcRenderer.send('paused-video') ipcRenderer.send('unblockPowerSave') } - if (action === 'playPause') { - state.video.isPaused = !state.video.isPaused - update() - } - if (action === 'playbackJump') { - state.video.jumpToTime = args[0] /* seconds */ - update() - } if (action === 'toggleFullScreen') { ipcRenderer.send('toggleFullScreen', args[0]) update() @@ -242,6 +235,23 @@ function dispatch (action, ...args) { } } +function playPause () { + if (Cast.isCasting()) { + Cast.playPause() + } + state.video.isPaused = !state.video.isPaused + update() +} + +function jumpToTime (time) { + if (Cast.isCasting()) { + Cast.seek(time) + } else { + state.video.jumpToTime = time + update() + } +} + function setupIpc () { ipcRenderer.on('dispatch', function (e, action, ...args) { dispatch(action, ...args) @@ -265,16 +275,6 @@ function setupIpc () { }) } -function detectDevices () { - chromecasts.on('update', function (player) { - state.devices.chromecast = player - }) - - airplay.createBrowser().on('deviceOn', function (player) { - state.devices.airplay = player - }).start() -} - // Load state.saved from the JSON state file function loadState (callback) { cfg.read(function (err, data) { @@ -599,25 +599,6 @@ function toggleSelectTorrent (infoHash) { update() } -function openChromecast () { - var torrentSummary = getTorrentSummary(state.playing.infoHash) - state.devices.chromecast.play(state.server.networkURL, { - title: config.APP_NAME + ' — ' + torrentSummary.name - }) - state.devices.chromecast.on('error', function (err) { - err.message = 'Chromecast: ' + err.message - onError(err) - }) - update() -} - -function openAirplay () { - state.devices.airplay.play(state.server.networkURL, 0, function () { - // TODO: handle airplay errors - }) - update() -} - // Set window dimensions to match video dimensions or fill the screen function setDimensions (dimensions) { state.window.bounds = { diff --git a/renderer/lib/cast.js b/renderer/lib/cast.js new file mode 100644 index 00000000..df638cda --- /dev/null +++ b/renderer/lib/cast.js @@ -0,0 +1,151 @@ +var chromecasts = require('chromecasts')() +var airplay = require('airplay-js') + +var config = require('../../config') +var state = require('../state') + +// The Cast module talks to Airplay and Chromecast +// * Modifies state when things change +// * Starts and stops casting, provides remote video controls +module.exports = { + init, + openChromecast, + openAirplay, + stopCasting, + playPause, + seek, + isCasting +} + +// Callback to notify module users when state has changed +var update + +function init (callback) { + update = callback + + // Start polling Chromecast or Airplay, whenever we're connected + setInterval(() => pollCastStatus(state), 1000) + + // Listen for devices: Chromecast and Airplay + chromecasts.on('update', function (player) { + state.devices.chromecast = player + addChromecastEvents() + }) + + airplay.createBrowser().on('deviceOn', function (player) { + state.devices.airplay = player + addAirplayEvents() + }).start() +} + +function addChromecastEvents () { + state.devices.chromecast.on('error', function (err) { + err.message = 'Chromecast: ' + err.message + onError(err) + }) + state.devices.chromecast.on('disconnect', function () { + state.playing.location = 'local' + update() + }) + state.devices.chromecast.on('status', handleStatus) +} + +function addAirplayEvents () {} + +// Update our state from the remote TV +function pollCastStatus(state) { + var device + if (state.playing.location === 'chromecast') device = state.devices.chromecast + else if (state.playing.location === 'airplay') device = state.devices.airplay + else return + + device.status(function (err, status) { + if (err) { + return console.log('Error retrieving %s status: %o', state.playing.location, err) + } + console.log('GOT CAST STATUS: %o', status) + handleStatus (status) + }) +} + +function handleStatus (status) { + state.video.isCastPaused = status.playerState === 'PAUSED' + state.video.currentTime = status.currentTime +} + +function openChromecast () { + if (state.playing.location !== 'local') { + throw new Error('You can\'t connect to Chromecast when already connected to another device') + } + + state.playing.location = 'chromecast-pending' + var torrentSummary = getTorrentSummary(state.playing.infoHash) + state.devices.chromecast.play(state.server.networkURL, { + type: 'video/mp4', + title: config.APP_NAME + ' — ' + torrentSummary.name + }, function (err) { + state.playing.location = err ? 'local' : 'chromecast' + update() + }) + update() +} + +function openAirplay () { + if (state.playing.location !== 'local') { + throw new Error('You can\'t connect to Airplay when already connected to another device') + } + + state.playing.location = 'airplay-pending' + state.devices.airplay.play(state.server.networkURL, 0, function () { + console.log('Airplay', arguments) // TODO: handle airplay errors + state.playing.location = 'airplay' + update() + }) + update() +} + +// Stops Chromecast or Airplay, move video back to local screen +function stopCasting () { + if (state.playing.location === 'chromecast') { + state.devices.chromecast.stop(stoppedCasting) + } else if (state.playing.location === 'airplay') { + throw new Error('Unimplemented') // TODO stop airplay + } else if (state.playing.location.endsWith('-pending')) { + // Connecting to Chromecast took too long or errored out. Let the user cancel + stoppedCasting() + } +} + +function stoppedCasting () { + state.playing.location = 'local' + state.video.jumpToTime = state.video.currentTime + update() +} + +// Checks whether we are connected and already casting +// Returns false if we not casting (state.playing.location === 'local') +// or if we're trying to connect but haven't yet ('chromecast-pending', etc) +function isCasting () { + return state.playing.location === 'chromecast' || state.playing.location === 'airplay' +} + +function playPause () { + var device = getActiveDevice() + if (!state.video.isPaused) device.pause(castCallback) + else device.play(null, null, castCallback) +} + +function seek (time) { + var device = getActiveDevice() + device.seek(time, castCallback) +} + +function getActiveDevice () { + if (state.playing.location === 'chromecast') return state.devices.chromecast + else if (state.playing.location === 'airplay') return state.devices.airplay + else throw new Error('getActiveDevice() called, but we\'re not casting') +} + +function castCallback (err) { + console.log('CAST CALLBACK: %o', arguments) +} diff --git a/renderer/state.js b/renderer/state.js index f16355d9..0f43d18f 100644 --- a/renderer/state.js +++ b/renderer/state.js @@ -11,34 +11,33 @@ module.exports = { client: null, /* the WebTorrent client */ server: null, /* local WebTorrent-to-HTTP server */ prev: {}, /* used for state diffing in updateElectron() */ - selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */ - playing: { - infoHash: null, /* the info hash of the torrent we're playing */ - fileIndex: null /* the zero-based index within the torrent */ - }, - // history: [], /* track how we got to the current view. enables Back button */ - // historyIndex: 0, url: 'home', - devices: { - airplay: null, /* airplay client. finds and manages AppleTVs */ - chromecast: null /* chromecast client. finds and manages Chromecasts */ - }, - dock: { - badge: 0, - progress: 0 - }, window: { bounds: null, /* x y width height */ isFocused: true, isFullScreen: false, title: config.APP_NAME /* current window title */ }, - video: { + selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */ + playing: { /* the torrent and file we're currently streaming */ + infoHash: null, /* the info hash of the torrent we're playing */ + fileIndex: null, /* the zero-based index within the torrent */ + location: 'local' /* 'local', 'chromecast', 'airplay' */ + }, + devices: { /* playback devices like Chromecast and AppleTV */ + airplay: null, /* airplay client. finds and manages AppleTVs */ + chromecast: null /* chromecast client. finds and manages Chromecasts */ + }, + video: { /* state of the video player screen */ currentTime: 0, /* seconds */ duration: 1, /* seconds */ isPaused: false, mouseStationarySince: 0 /* Unix time in ms */ }, + dock: { + badge: 0, + progress: 0 + }, /* * Saved state is read from and written to a file every time the app runs. diff --git a/renderer/views/app.js b/renderer/views/app.js index 844ef045..c7e066b0 100644 --- a/renderer/views/app.js +++ b/renderer/views/app.js @@ -16,10 +16,12 @@ function App (state, dispatch) { // Never hide the controls when: // * The mouse is over the controls or we're scrubbing (see CSS) // * The video is paused + // * The video is playing remotely on Chromecast or Airplay var hideControls = state.url === 'player' && state.video.mouseStationarySince !== 0 && new Date().getTime() - state.video.mouseStationarySince > 2000 && - !state.video.isPaused + !state.video.isPaused && + state.video.location === 'local' var cls = [ 'view-' + state.url, /* e.g. view-home, view-player */ diff --git a/renderer/views/player.js b/renderer/views/player.js index 4cd1599b..aafb81d7 100644 --- a/renderer/views/player.js +++ b/renderer/views/player.js @@ -4,7 +4,22 @@ var h = require('virtual-dom/h') var hyperx = require('hyperx') var hx = hyperx(h) +// Shows a streaming video player. Standard features + Chromecast + Airplay function Player (state, dispatch) { + // Show the video as large as will fit in the window, play immediately + // If the video is on Chromecast or Airplay, show a title screen instead + var showVideo = state.playing.location === 'local' + return hx` +
dispatch('videoMouseMoved')}> + ${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)} + ${renderPlayerControls(state, dispatch)} +
+ ` +} + +function renderVideo (state, dispatch) { // Unfortunately, play/pause can't be done just by modifying HTML. // Instead, grab the DOM node and play/pause it if necessary var videoElement = document.querySelector('video') @@ -23,25 +38,19 @@ function Player (state, dispatch) { state.video.duration = videoElement.duration } - // Show the video as large as will fit in the window, play immediately return hx`
dispatch('videoMouseMoved')}> -
dispatch('videoMouseMoved')}> - -
- ${renderPlayerControls(state, dispatch)} +
` @@ -61,6 +70,39 @@ function Player (state, dispatch) { } } +function renderCastScreen (state, dispatch) { + var isChromecast = state.playing.location.startsWith('chromecast') + var isAirplay = state.playing.location.startsWith('airplay') + var isStarting = state.playing.location.endsWith('-pending') + if (!isChromecast && !isAirplay) throw new Error('Unimplemented cast type') + + // Finally, show a static title screen and the cast status + var header = isChromecast ? 'Chromecast' : 'AirPlay' + var content + if (isStarting) { + content = hx` +
Connecting...
+ ` + } else { + content = hx` +
+
dispatch('stopCasting')}> + Stop Casting +
+
+ ` + } + return hx` +
+
+

${header}

+ ${content} +
+
+ ` +} + function renderPlayerControls (state, dispatch) { var positionPercent = 100 * state.video.currentTime / state.video.duration var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } @@ -79,27 +121,50 @@ function renderPlayerControls (state, dispatch) { hx` dispatch('toggleFullScreen')}> - fullscreen + ${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'} ` ] + // If we've detected a Chromecast or AppleTV, the user can play video there - if (state.devices.chromecast) { + var isOnChromecast = state.playing.location.startsWith('chromecast') + var isOnAirplay = state.playing.location.startsWith('airplay') + var chromecastClass, chromecastHandler, airplayClass, airplayHandler + if (isOnChromecast) { + chromecastClass = 'active' + airplayClass = 'disabled' + chromecastHandler = () => dispatch('stopCasting') + airplayHandler = undefined + } else if (isOnAirplay) { + chromecastClass = 'disabled' + airplayClass = 'active' + chromecastHandler = undefined + airplayHandler = () => dispatch('stopCasting') + } else { + chromecastClass = '' + airplayClass = '' + chromecastHandler = () => dispatch('openChromecast') + airplayHandler = () => dispatch('openAirplay') + } + if (state.devices.chromecast || isOnChromecast) { elements.push(hx` dispatch('openChromecast')}> + class=${chromecastClass} + onclick=${chromecastHandler}> cast `) } - if (state.devices.airplay) { + if (state.devices.airplay || isOnAirplay) { elements.push(hx` dispatch('openAirplay')}> + class=${airplayClass} + onclick=${airplayHandler}> airplay `) } + // On OSX, the back button is in the title bar of the window; see app.js // On other platforms, we render one over the video on mouseover if (process.platform !== 'darwin') { @@ -110,6 +175,8 @@ function renderPlayerControls (state, dispatch) { `) } + + // Finally, the big button in the center plays or pauses the video elements.push(hx` dispatch('playPause')}> ${state.video.isPaused ? 'play_arrow' : 'pause'}