Chromecast video controls
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
151
renderer/lib/cast.js
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user