WebTorrent can now play audio

This commit is contained in:
DC
2016-03-22 02:07:57 -07:00
parent 90fd755363
commit fc425e4221
8 changed files with 134 additions and 85 deletions

View File

@@ -181,7 +181,6 @@ i:not(.disabled):hover {
left: 0; left: 0;
top: 0; top: 0;
right: 0; right: 0;
z-index: 1000;
transition: opacity 0.15s ease-out; transition: opacity 0.15s ease-out;
font-size: 14px; font-size: 14px;
line-height: 1.5em; line-height: 1.5em;
@@ -456,8 +455,7 @@ input {
background-color: #F44336; background-color: #F44336;
} }
.torrent.timeout .play, .torrent.timeout .play {
.torrent.unplayable .play {
padding-top: 8px; padding-top: 8px;
} }
@@ -738,7 +736,6 @@ body.drag .torrent-placeholder span {
.error-popover { .error-popover {
position: fixed; position: fixed;
z-index: 1001;
top: 36px; top: 36px;
margin: 0; margin: 0;
width: 100%; width: 100%;

View File

@@ -163,7 +163,7 @@ function updateElectron () {
// Events from the UI never modify state directly. Instead they call dispatch() // Events from the UI never modify state directly. Instead they call dispatch()
function dispatch (action, ...args) { function dispatch (action, ...args) {
if (['videoMouseMoved', 'playbackJump'].indexOf(action) === -1) { if (['mediaMouseMoved', 'playbackJump'].indexOf(action) === -1) {
console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */ console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */
} }
if (action === 'onOpen') { if (action === 'onOpen') {
@@ -235,20 +235,20 @@ function dispatch (action, ...args) {
if (action === 'playbackJump') { if (action === 'playbackJump') {
jumpToTime(args[0] /* seconds */) jumpToTime(args[0] /* seconds */)
} }
if (action === 'videoPlaying') { if (action === 'mediaPlaying') {
state.video.isPaused = false state.playing.isPaused = false
ipcRenderer.send('blockPowerSave') ipcRenderer.send('blockPowerSave')
} }
if (action === 'videoPaused') { if (action === 'mediaPaused') {
state.video.isPaused = true state.playing.isPaused = true
ipcRenderer.send('unblockPowerSave') ipcRenderer.send('unblockPowerSave')
} }
if (action === 'toggleFullScreen') { if (action === 'toggleFullScreen') {
ipcRenderer.send('toggleFullScreen', args[0]) ipcRenderer.send('toggleFullScreen', args[0])
update() update()
} }
if (action === 'videoMouseMoved') { if (action === 'mediaMouseMoved') {
state.video.mouseStationarySince = new Date().getTime() state.playing.mouseStationarySince = new Date().getTime()
update() update()
} }
if (action === 'exitModal') { if (action === 'exitModal') {
@@ -259,14 +259,14 @@ function dispatch (action, ...args) {
// Plays or pauses the video. If isPaused is undefined, acts as a toggle // Plays or pauses the video. If isPaused is undefined, acts as a toggle
function playPause (isPaused) { function playPause (isPaused) {
if (isPaused === state.video.isPaused) { if (isPaused === state.playing.isPaused) {
return // Nothing to do return // Nothing to do
} }
// Either isPaused is undefined, or it's the opposite of the current state. Toggle. // Either isPaused is undefined, or it's the opposite of the current state. Toggle.
if (Cast.isCasting()) { if (Cast.isCasting()) {
Cast.playPause() Cast.playPause()
} }
state.video.isPaused = !state.video.isPaused state.playing.isPaused = !state.playing.isPaused
update() update()
} }
@@ -274,7 +274,7 @@ function jumpToTime (time) {
if (Cast.isCasting()) { if (Cast.isCasting()) {
Cast.seek(time) Cast.seek(time)
} else { } else {
state.video.jumpToTime = time state.playing.jumpToTime = time
update() update()
} }
} }
@@ -601,20 +601,14 @@ function startServer (torrentSummary, index, cb) {
function startServerFromReadyTorrent (torrent, index, cb) { function startServerFromReadyTorrent (torrent, index, cb) {
// automatically choose which file in the torrent to play, if necessary // automatically choose which file in the torrent to play, if necessary
if (!index) { if (index === undefined) index = pickFileToPlay(torrent.files)
// filter out file formats that the <video> tag definitely can't play if (index === undefined) return cb(new errors.UnplayableError())
var files = torrent.files.filter(TorrentPlayer.isPlayable) var file = torrent.files[index]
if (files.length === 0) return cb(new errors.UnplayableError())
// use largest file
var largestFile = files.reduce(function (a, b) {
return a.length > b.length ? a : b
})
index = torrent.files.indexOf(largestFile)
}
// update state // update state
state.playing.infoHash = torrent.infoHash state.playing.infoHash = torrent.infoHash
state.playing.fileIndex = index state.playing.fileIndex = index
state.playing.type = TorrentPlayer.isVideo(file) ? 'video' : 'audio'
var server = torrent.createServer() var server = torrent.createServer()
server.listen(0, function () { server.listen(0, function () {
@@ -629,6 +623,28 @@ function startServerFromReadyTorrent (torrent, index, cb) {
}) })
} }
// Picks the default file to play from a list of torrent or torrentSummary files
// Returns an index or undefined, if no files are playable
function pickFileToPlay (files) {
// first, try to find the biggest video file
var videoFiles = files.filter(TorrentPlayer.isVideo)
if (videoFiles.length > 0) {
var largestVideoFile = videoFiles.reduce(function (a, b) {
return a.length > b.length ? a : b
})
return files.indexOf(largestVideoFile)
}
// if there are no videos, play the first audio file
var audioFiles = files.filter(TorrentPlayer.isAudio)
if (audioFiles.length > 0) {
return files.indexOf(audioFiles[0])
}
// no video or audio means nothing is playable
return undefined
}
function stopServer () { function stopServer () {
if (!state.server) return if (!state.server) return
state.server.server.destroy() state.server.server.destroy()

View File

@@ -57,14 +57,14 @@ function pollCastStatus (state) {
if (state.playing.location === 'chromecast') { if (state.playing.location === 'chromecast') {
state.devices.chromecast.status(function (err, status) { state.devices.chromecast.status(function (err, status) {
if (err) return console.log('Error getting %s status: %o', state.playing.location, err) if (err) return console.log('Error getting %s status: %o', state.playing.location, err)
state.video.isPaused = status.playerState === 'PAUSED' state.playing.isPaused = status.playerState === 'PAUSED'
state.video.currentTime = status.currentTime state.playing.currentTime = status.currentTime
update() update()
}) })
} else if (state.playing.location === 'airplay') { } else if (state.playing.location === 'airplay') {
state.devices.airplay.status(function (status) { state.devices.airplay.status(function (status) {
state.video.isPaused = status.rate === 0 state.playing.isPaused = status.rate === 0
state.video.currentTime = status.position state.playing.currentTime = status.position
update() update()
}) })
} }
@@ -122,7 +122,7 @@ function stopCasting () {
function stoppedCasting () { function stoppedCasting () {
state.playing.location = 'local' state.playing.location = 'local'
state.video.jumpToTime = state.video.currentTime state.playing.jumpToTime = state.playing.currentTime
update() update()
} }
@@ -137,11 +137,11 @@ function playPause () {
var device var device
if (state.playing.location === 'chromecast') { if (state.playing.location === 'chromecast') {
device = state.devices.chromecast device = state.devices.chromecast
if (!state.video.isPaused) device.pause(castCallback) if (!state.playing.isPaused) device.pause(castCallback)
else device.play(null, null, castCallback) else device.play(null, null, castCallback)
} else if (state.playing.location === 'airplay') { } else if (state.playing.location === 'airplay') {
device = state.devices.airplay device = state.devices.airplay
if (!state.video.isPaused) device.rate(0, castCallback) if (!state.playing.isPaused) device.rate(0, castCallback)
else device.rate(1, castCallback) else device.rate(1, castCallback)
} }
} }

View File

@@ -1,5 +1,7 @@
module.exports = { module.exports = {
isPlayable: isPlayable isPlayable,
isVideo,
isAudio
} }
var path = require('path') var path = require('path')
@@ -8,6 +10,15 @@ var path = require('path')
* Determines whether a file in a torrent is audio/video we can play * Determines whether a file in a torrent is audio/video we can play
*/ */
function isPlayable (file) { function isPlayable (file) {
var extname = path.extname(file.name) return isVideo(file) || isAudio(file)
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(extname) !== -1 }
function isVideo (file) {
var ext = path.extname(file.name)
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(ext) !== -1
}
function isAudio (file) {
var ext = path.extname(file.name)
return ['.mp3', '.aac', '.ogg', '.wav'].indexOf(ext) !== -1
} }

View File

@@ -20,21 +20,20 @@ module.exports = {
title: config.APP_NAME /* current window title */ title: config.APP_NAME /* current window title */
}, },
selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */ selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */
playing: { /* the torrent and file we're currently streaming */ playing: { /* the media (audio or video) that we're currently playing */
infoHash: null, /* the info hash of the torrent we're playing */ infoHash: null, /* the info hash of the torrent we're playing */
fileIndex: null, /* the zero-based index within the torrent */ fileIndex: null, /* the zero-based index within the torrent */
location: 'local' /* 'local', 'chromecast', 'airplay' */ location: 'local', /* 'local', 'chromecast', 'airplay' */
}, type: null, /* 'audio' or 'video' */
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 */ currentTime: 0, /* seconds */
duration: 1, /* seconds */ duration: 1, /* seconds */
isPaused: true, isPaused: true,
mouseStationarySince: 0 /* Unix time in ms */ mouseStationarySince: 0 /* Unix time in ms */
}, },
devices: { /* playback devices like Chromecast and AppleTV */
airplay: null, /* airplay client. finds and manages AppleTVs */
chromecast: null /* chromecast client. finds and manages Chromecasts */
},
dock: { dock: {
badge: 0, badge: 0,
progress: 0 progress: 0

View File

@@ -18,9 +18,9 @@ function App (state, dispatch) {
// * The video is paused // * The video is paused
// * The video is playing remotely on Chromecast or Airplay // * The video is playing remotely on Chromecast or Airplay
var hideControls = state.location.current().url === 'player' && var hideControls = state.location.current().url === 'player' &&
state.video.mouseStationarySince !== 0 && state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.video.mouseStationarySince > 2000 && new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.video.isPaused && !state.playing.isPaused &&
state.playing.location === 'local' state.playing.location === 'local'
// Hide the header on Windows/Linux when in the player // Hide the header on Windows/Linux when in the player

View File

@@ -4,6 +4,7 @@ var h = require('virtual-dom/h')
var hyperx = require('hyperx') var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
// Shows a streaming video player. Standard features + Chromecast + Airplay // Shows a streaming video player. Standard features + Chromecast + Airplay
function Player (state, dispatch) { function Player (state, dispatch) {
// Show the video as large as will fit in the window, play immediately // Show the video as large as will fit in the window, play immediately
@@ -12,50 +13,65 @@ function Player (state, dispatch) {
return hx` return hx`
<div <div
class='player' class='player'
onmousemove=${() => dispatch('videoMouseMoved')}> onmousemove=${() => dispatch('mediaMouseMoved')}>
${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)} ${showVideo ? renderMedia(state, dispatch) : renderCastScreen(state, dispatch)}
${renderPlayerControls(state, dispatch)} ${renderPlayerControls(state, dispatch)}
</div> </div>
` `
} }
function renderVideo (state, dispatch) { function renderMedia (state, dispatch) {
// Unfortunately, play/pause can't be done just by modifying HTML. // Unfortunately, play/pause can't be done just by modifying HTML.
// Instead, grab the DOM node and play/pause it if necessary // Instead, grab the DOM node and play/pause it if necessary
var videoElement = document.querySelector('video') var mediaType = state.playing.type /* 'audio' or 'video' */
if (videoElement !== null) { var mediaElement = document.querySelector(mediaType) /* get the <video> or <audio> tag */
if (state.video.isPaused && !videoElement.paused) { if (mediaElement !== null) {
videoElement.pause() if (state.playing.isPaused && !mediaElement.paused) {
} else if (!state.video.isPaused && videoElement.paused) { mediaElement.pause()
videoElement.play() } else if (!state.playing.isPaused && mediaElement.paused) {
mediaElement.play()
} }
// When the user clicks or drags on the progress bar, jump to that position // When the user clicks or drags on the progress bar, jump to that position
if (state.video.jumpToTime) { if (state.playing.jumpToTime) {
videoElement.currentTime = state.video.jumpToTime mediaElement.currentTime = state.playing.jumpToTime
state.video.jumpToTime = null state.playing.jumpToTime = null
} }
state.video.currentTime = videoElement.currentTime state.playing.currentTime = mediaElement.currentTime
state.video.duration = videoElement.duration state.playing.duration = mediaElement.duration
} }
return hx` // Create the <audio> or <video> tag
var mediaTag = hx`
<div <div
class='letterbox'
onmousemove=${() => dispatch('videoMouseMoved')}>
<video
src='${state.server.localURL}' src='${state.server.localURL}'
ondblclick=${() => dispatch('toggleFullScreen')} ondblclick=${() => dispatch('toggleFullScreen')}
onloadedmetadata=${onLoadedMetadata} onloadedmetadata=${onLoadedMetadata}
onended=${onEnded} onended=${onEnded}
onplay=${() => dispatch('videoPlaying')} onplay=${() => dispatch('mediaPlaying')}
onpause=${() => dispatch('videoPaused')} onpause=${() => dispatch('mediaPaused')}
autoplay> autoplay>
</video> </div>
`
mediaTag.tagName = mediaType
// Show the media.
// Video fills the window, centered with black bars if necessary
// Audio gets a static poster image and a summary of the file metadata.
var style = {
backgroundImage: mediaType === 'audio' ? cssBackgroundImagePoster(state) : ''
}
return hx`
<div
class='letterbox'
style=${style}
onmousemove=${() => dispatch('mediaMouseMoved')}>
${mediaTag}
</div> </div>
` `
// As soon as the video loads enough to know the video dimensions, resize the window // As soon as the video loads enough to know the video dimensions, resize the window
function onLoadedMetadata (e) { function onLoadedMetadata (e) {
if (mediaType !== 'video') return
var video = e.target var video = e.target
var dimensions = { var dimensions = {
width: video.videoWidth, width: video.videoWidth,
@@ -66,7 +82,7 @@ function renderVideo (state, dispatch) {
// When the video completes, pause the video instead of looping // When the video completes, pause the video instead of looping
function onEnded (e) { function onEnded (e) {
state.video.isPaused = true state.playing.isPaused = true
} }
} }
@@ -77,12 +93,8 @@ function renderCastScreen (state, dispatch) {
if (!isChromecast && !isAirplay) throw new Error('Unimplemented cast type') if (!isChromecast && !isAirplay) throw new Error('Unimplemented cast type')
// Show a nice title image, if possible // Show a nice title image, if possible
var style = {} var style = {
var infoHash = state.playing.infoHash backgroundImage: cssBackgroundImagePoster(state)
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === infoHash)
if (torrentSummary && torrentSummary.posterURL) {
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
style.backgroundImage = `radial-gradient(circle at center, rgba(0,0,0,0.4) 0%,rgba(0,0,0,1) 100%), url(${cleanURL})`
} }
// Show whether we're connected to Chromecast / Airplay // Show whether we're connected to Chromecast / Airplay
@@ -98,8 +110,19 @@ function renderCastScreen (state, dispatch) {
` `
} }
// Returns the CSS background-image string for a poster image + dark vignette
function cssBackgroundImagePoster (state) {
var infoHash = state.playing.infoHash
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === infoHash)
if (!torrentSummary || !torrentSummary.posterURL) return ''
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
return 'radial-gradient(circle at center, ' +
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' +
`, url(${cleanURL})`
}
function renderPlayerControls (state, dispatch) { function renderPlayerControls (state, dispatch) {
var positionPercent = 100 * state.video.currentTime / state.video.duration var positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' }
var elements = [ var elements = [
@@ -174,7 +197,7 @@ function renderPlayerControls (state, dispatch) {
// Finally, the big button in the center plays or pauses the video // Finally, the big button in the center plays or pauses the video
elements.push(hx` elements.push(hx`
<i class='icon play-pause' onclick=${() => dispatch('playPause')}> <i class='icon play-pause' onclick=${() => dispatch('playPause')}>
${state.video.isPaused ? 'play_arrow' : 'pause'} ${state.playing.isPaused ? 'play_arrow' : 'pause'}
</i> </i>
`) `)
@@ -182,10 +205,10 @@ function renderPlayerControls (state, dispatch) {
// Handles a click or drag to scrub (jump to another position in the video) // Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) { function handleScrub (e) {
dispatch('videoMouseMoved') dispatch('mediaMouseMoved')
var windowWidth = document.querySelector('body').clientWidth var windowWidth = document.querySelector('body').clientWidth
var fraction = e.clientX / windowWidth var fraction = e.clientX / windowWidth
var position = fraction * state.video.duration /* seconds */ var position = fraction * state.playing.duration /* seconds */
dispatch('playbackJump', position) dispatch('playbackJump', position)
} }
} }

View File

@@ -103,9 +103,10 @@ function TorrentList (state, dispatch) {
// Download button toggles between torrenting (DL/seed) and paused // Download button toggles between torrenting (DL/seed) and paused
// Play button starts streaming the torrent immediately, unpausing if needed // Play button starts streaming the torrent immediately, unpausing if needed
function renderTorrentButtons (torrentSummary) { function renderTorrentButtons (torrentSummary) {
var playIcon, playTooltip var playIcon, playTooltip, playClass
if (torrentSummary.playStatus === 'unplayable') { if (torrentSummary.playStatus === 'unplayable') {
playIcon = 'warning' playIcon = 'play_arrow'
playClass = 'disabled'
playTooltip = 'Sorry, WebTorrent can\'t play any of the files in this torrent. ' + playTooltip = 'Sorry, WebTorrent can\'t play any of the files in this torrent. ' +
'View details and click on individual files to open them in another program.' 'View details and click on individual files to open them in another program.'
} else if (torrentSummary.playStatus === 'timeout') { } else if (torrentSummary.playStatus === 'timeout') {
@@ -131,13 +132,14 @@ function TorrentList (state, dispatch) {
return hx` return hx`
<div class='buttons'> <div class='buttons'>
<i.btn.icon.play <i.btn.icon.play
title='${playTooltip}' title=${playTooltip}
class=${playClass}
onclick=${(e) => handleButton('play', e)}> onclick=${(e) => handleButton('play', e)}>
${playIcon} ${playIcon}
</i> </i>
<i.btn.icon.download <i.btn.icon.download
class='${torrentSummary.status}' class=${torrentSummary.status}
title='${downloadTooltip}' title=${downloadTooltip}
onclick=${(e) => handleButton('toggleTorrent', e)}> onclick=${(e) => handleButton('toggleTorrent', e)}>
${downloadIcon} ${downloadIcon}
</i> </i>
@@ -153,6 +155,7 @@ function TorrentList (state, dispatch) {
function handleButton (action, e) { function handleButton (action, e) {
// Prevent propagation so that we don't select/unselect the torrent // Prevent propagation so that we don't select/unselect the torrent
e.stopPropagation() e.stopPropagation()
if (e.target.classList.contains('disabled')) return
dispatch(action, torrentSummary) dispatch(action, torrentSummary)
} }
} }