Chromecast video controls

This commit is contained in:
DC
2016-03-14 23:01:35 -07:00
parent 7437e82eb5
commit 849bbed0ae
6 changed files with 322 additions and 89 deletions

View File

@@ -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
*/

View File

@@ -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 = {

151
renderer/lib/cast.js Normal file
View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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 */

View File

@@ -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`
<div
class='player'
onmousemove=${() => dispatch('videoMouseMoved')}>
${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)}
${renderPlayerControls(state, dispatch)}
</div>
`
}
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`
<div
class='player'
class='letterbox'
onmousemove=${() => dispatch('videoMouseMoved')}>
<div
class='letterbox'
onmousemove=${() => dispatch('videoMouseMoved')}>
<video
src='${state.server.localURL}'
ondblclick=${() => dispatch('toggleFullScreen')}
onloadedmetadata=${onLoadedMetadata}
onended=${onEnded}
onplay=${() => dispatch('videoPlaying')}
onpause=${() => dispatch('videoPaused')}
autoplay>
</video>
</div>
${renderPlayerControls(state, dispatch)}
<video
src='${state.server.localURL}'
ondblclick=${() => dispatch('toggleFullScreen')}
onloadedmetadata=${onLoadedMetadata}
onended=${onEnded}
onplay=${() => dispatch('videoPlaying')}
onpause=${() => dispatch('videoPaused')}
autoplay>
</video>
</div>
`
@@ -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`
<div class='cast-status'>Connecting...</div>
`
} else {
content = hx`
<div class='cast-status'>
<div class='button stop-casting'
onclick=${() => dispatch('stopCasting')}>
Stop Casting
</div>
</div>
`
}
return hx`
<div class='letterbox'>
<div class='cast-screen'>
<h1>${header}</h1>
${content}
</div>
</div>
`
}
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`
<i class='icon fullscreen'
onclick=${() => dispatch('toggleFullScreen')}>
fullscreen
${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
</i>
`
]
// 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`
<i.icon.chromecast
onclick=${() => dispatch('openChromecast')}>
class=${chromecastClass}
onclick=${chromecastHandler}>
cast
</i>
`)
}
if (state.devices.airplay) {
if (state.devices.airplay || isOnAirplay) {
elements.push(hx`
<i.icon.airplay
onclick=${() => dispatch('openAirplay')}>
class=${airplayClass}
onclick=${airplayHandler}>
airplay
</i>
`)
}
// 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) {
</i>
`)
}
// Finally, the big button in the center plays or pauses the video
elements.push(hx`
<i class='icon play-pause' onclick=${() => dispatch('playPause')}>
${state.video.isPaused ? 'play_arrow' : 'pause'}