WebTorrent can now play audio
This commit is contained in:
@@ -181,7 +181,6 @@ i:not(.disabled):hover {
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease-out;
|
||||
font-size: 14px;
|
||||
line-height: 1.5em;
|
||||
@@ -456,8 +455,7 @@ input {
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
.torrent.timeout .play,
|
||||
.torrent.unplayable .play {
|
||||
.torrent.timeout .play {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -738,7 +736,6 @@ body.drag .torrent-placeholder span {
|
||||
|
||||
.error-popover {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
top: 36px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -163,7 +163,7 @@ function updateElectron () {
|
||||
|
||||
// Events from the UI never modify state directly. Instead they call dispatch()
|
||||
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 */
|
||||
}
|
||||
if (action === 'onOpen') {
|
||||
@@ -235,20 +235,20 @@ function dispatch (action, ...args) {
|
||||
if (action === 'playbackJump') {
|
||||
jumpToTime(args[0] /* seconds */)
|
||||
}
|
||||
if (action === 'videoPlaying') {
|
||||
state.video.isPaused = false
|
||||
if (action === 'mediaPlaying') {
|
||||
state.playing.isPaused = false
|
||||
ipcRenderer.send('blockPowerSave')
|
||||
}
|
||||
if (action === 'videoPaused') {
|
||||
state.video.isPaused = true
|
||||
if (action === 'mediaPaused') {
|
||||
state.playing.isPaused = true
|
||||
ipcRenderer.send('unblockPowerSave')
|
||||
}
|
||||
if (action === 'toggleFullScreen') {
|
||||
ipcRenderer.send('toggleFullScreen', args[0])
|
||||
update()
|
||||
}
|
||||
if (action === 'videoMouseMoved') {
|
||||
state.video.mouseStationarySince = new Date().getTime()
|
||||
if (action === 'mediaMouseMoved') {
|
||||
state.playing.mouseStationarySince = new Date().getTime()
|
||||
update()
|
||||
}
|
||||
if (action === 'exitModal') {
|
||||
@@ -259,14 +259,14 @@ function dispatch (action, ...args) {
|
||||
|
||||
// Plays or pauses the video. If isPaused is undefined, acts as a toggle
|
||||
function playPause (isPaused) {
|
||||
if (isPaused === state.video.isPaused) {
|
||||
if (isPaused === state.playing.isPaused) {
|
||||
return // Nothing to do
|
||||
}
|
||||
// Either isPaused is undefined, or it's the opposite of the current state. Toggle.
|
||||
if (Cast.isCasting()) {
|
||||
Cast.playPause()
|
||||
}
|
||||
state.video.isPaused = !state.video.isPaused
|
||||
state.playing.isPaused = !state.playing.isPaused
|
||||
update()
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ function jumpToTime (time) {
|
||||
if (Cast.isCasting()) {
|
||||
Cast.seek(time)
|
||||
} else {
|
||||
state.video.jumpToTime = time
|
||||
state.playing.jumpToTime = time
|
||||
update()
|
||||
}
|
||||
}
|
||||
@@ -601,20 +601,14 @@ function startServer (torrentSummary, index, cb) {
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
// automatically choose which file in the torrent to play, if necessary
|
||||
if (!index) {
|
||||
// filter out file formats that the <video> tag definitely can't play
|
||||
var files = torrent.files.filter(TorrentPlayer.isPlayable)
|
||||
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)
|
||||
}
|
||||
if (index === undefined) index = pickFileToPlay(torrent.files)
|
||||
if (index === undefined) return cb(new errors.UnplayableError())
|
||||
var file = torrent.files[index]
|
||||
|
||||
// update state
|
||||
state.playing.infoHash = torrent.infoHash
|
||||
state.playing.fileIndex = index
|
||||
state.playing.type = TorrentPlayer.isVideo(file) ? 'video' : 'audio'
|
||||
|
||||
var server = torrent.createServer()
|
||||
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 () {
|
||||
if (!state.server) return
|
||||
state.server.server.destroy()
|
||||
|
||||
@@ -57,14 +57,14 @@ function pollCastStatus (state) {
|
||||
if (state.playing.location === 'chromecast') {
|
||||
state.devices.chromecast.status(function (err, status) {
|
||||
if (err) return console.log('Error getting %s status: %o', state.playing.location, err)
|
||||
state.video.isPaused = status.playerState === 'PAUSED'
|
||||
state.video.currentTime = status.currentTime
|
||||
state.playing.isPaused = status.playerState === 'PAUSED'
|
||||
state.playing.currentTime = status.currentTime
|
||||
update()
|
||||
})
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
state.devices.airplay.status(function (status) {
|
||||
state.video.isPaused = status.rate === 0
|
||||
state.video.currentTime = status.position
|
||||
state.playing.isPaused = status.rate === 0
|
||||
state.playing.currentTime = status.position
|
||||
update()
|
||||
})
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function stopCasting () {
|
||||
|
||||
function stoppedCasting () {
|
||||
state.playing.location = 'local'
|
||||
state.video.jumpToTime = state.video.currentTime
|
||||
state.playing.jumpToTime = state.playing.currentTime
|
||||
update()
|
||||
}
|
||||
|
||||
@@ -137,11 +137,11 @@ function playPause () {
|
||||
var device
|
||||
if (state.playing.location === '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 if (state.playing.location === '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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
module.exports = {
|
||||
isPlayable: isPlayable
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
function isPlayable (file) {
|
||||
var extname = path.extname(file.name)
|
||||
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(extname) !== -1
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -20,21 +20,20 @@ module.exports = {
|
||||
title: config.APP_NAME /* current window title */
|
||||
},
|
||||
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 */
|
||||
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 */
|
||||
location: 'local', /* 'local', 'chromecast', 'airplay' */
|
||||
type: null, /* 'audio' or 'video' */
|
||||
currentTime: 0, /* seconds */
|
||||
duration: 1, /* seconds */
|
||||
isPaused: true,
|
||||
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: {
|
||||
badge: 0,
|
||||
progress: 0
|
||||
|
||||
@@ -18,9 +18,9 @@ function App (state, dispatch) {
|
||||
// * The video is paused
|
||||
// * The video is playing remotely on Chromecast or Airplay
|
||||
var hideControls = state.location.current().url === 'player' &&
|
||||
state.video.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.video.mouseStationarySince > 2000 &&
|
||||
!state.video.isPaused &&
|
||||
state.playing.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
|
||||
!state.playing.isPaused &&
|
||||
state.playing.location === 'local'
|
||||
|
||||
// Hide the header on Windows/Linux when in the player
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -12,50 +13,65 @@ function Player (state, dispatch) {
|
||||
return hx`
|
||||
<div
|
||||
class='player'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)}
|
||||
onmousemove=${() => dispatch('mediaMouseMoved')}>
|
||||
${showVideo ? renderMedia(state, dispatch) : renderCastScreen(state, dispatch)}
|
||||
${renderPlayerControls(state, dispatch)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderVideo (state, dispatch) {
|
||||
function renderMedia (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')
|
||||
if (videoElement !== null) {
|
||||
if (state.video.isPaused && !videoElement.paused) {
|
||||
videoElement.pause()
|
||||
} else if (!state.video.isPaused && videoElement.paused) {
|
||||
videoElement.play()
|
||||
var mediaType = state.playing.type /* 'audio' or 'video' */
|
||||
var mediaElement = document.querySelector(mediaType) /* get the <video> or <audio> tag */
|
||||
if (mediaElement !== null) {
|
||||
if (state.playing.isPaused && !mediaElement.paused) {
|
||||
mediaElement.pause()
|
||||
} else if (!state.playing.isPaused && mediaElement.paused) {
|
||||
mediaElement.play()
|
||||
}
|
||||
// When the user clicks or drags on the progress bar, jump to that position
|
||||
if (state.video.jumpToTime) {
|
||||
videoElement.currentTime = state.video.jumpToTime
|
||||
state.video.jumpToTime = null
|
||||
if (state.playing.jumpToTime) {
|
||||
mediaElement.currentTime = state.playing.jumpToTime
|
||||
state.playing.jumpToTime = null
|
||||
}
|
||||
state.video.currentTime = videoElement.currentTime
|
||||
state.video.duration = videoElement.duration
|
||||
state.playing.currentTime = mediaElement.currentTime
|
||||
state.playing.duration = mediaElement.duration
|
||||
}
|
||||
|
||||
// Create the <audio> or <video> tag
|
||||
var mediaTag = hx`
|
||||
<div
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${() => dispatch('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onplay=${() => dispatch('mediaPlaying')}
|
||||
onpause=${() => dispatch('mediaPaused')}
|
||||
autoplay>
|
||||
</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'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
<video
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${() => dispatch('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onplay=${() => dispatch('videoPlaying')}
|
||||
onpause=${() => dispatch('videoPaused')}
|
||||
autoplay>
|
||||
</video>
|
||||
style=${style}
|
||||
onmousemove=${() => dispatch('mediaMouseMoved')}>
|
||||
${mediaTag}
|
||||
</div>
|
||||
`
|
||||
|
||||
// As soon as the video loads enough to know the video dimensions, resize the window
|
||||
function onLoadedMetadata (e) {
|
||||
if (mediaType !== 'video') return
|
||||
var video = e.target
|
||||
var dimensions = {
|
||||
width: video.videoWidth,
|
||||
@@ -66,7 +82,7 @@ function renderVideo (state, dispatch) {
|
||||
|
||||
// When the video completes, pause the video instead of looping
|
||||
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')
|
||||
|
||||
// Show a nice title image, if possible
|
||||
var style = {}
|
||||
var infoHash = state.playing.infoHash
|
||||
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})`
|
||||
var style = {
|
||||
backgroundImage: cssBackgroundImagePoster(state)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 elements = [
|
||||
@@ -174,7 +197,7 @@ function renderPlayerControls (state, dispatch) {
|
||||
// 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'}
|
||||
${state.playing.isPaused ? 'play_arrow' : 'pause'}
|
||||
</i>
|
||||
`)
|
||||
|
||||
@@ -182,10 +205,10 @@ function renderPlayerControls (state, dispatch) {
|
||||
|
||||
// Handles a click or drag to scrub (jump to another position in the video)
|
||||
function handleScrub (e) {
|
||||
dispatch('videoMouseMoved')
|
||||
dispatch('mediaMouseMoved')
|
||||
var windowWidth = document.querySelector('body').clientWidth
|
||||
var fraction = e.clientX / windowWidth
|
||||
var position = fraction * state.video.duration /* seconds */
|
||||
var position = fraction * state.playing.duration /* seconds */
|
||||
dispatch('playbackJump', position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,10 @@ function TorrentList (state, dispatch) {
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
function renderTorrentButtons (torrentSummary) {
|
||||
var playIcon, playTooltip
|
||||
var playIcon, playTooltip, playClass
|
||||
if (torrentSummary.playStatus === 'unplayable') {
|
||||
playIcon = 'warning'
|
||||
playIcon = 'play_arrow'
|
||||
playClass = 'disabled'
|
||||
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.'
|
||||
} else if (torrentSummary.playStatus === 'timeout') {
|
||||
@@ -131,13 +132,14 @@ function TorrentList (state, dispatch) {
|
||||
return hx`
|
||||
<div class='buttons'>
|
||||
<i.btn.icon.play
|
||||
title='${playTooltip}'
|
||||
title=${playTooltip}
|
||||
class=${playClass}
|
||||
onclick=${(e) => handleButton('play', e)}>
|
||||
${playIcon}
|
||||
</i>
|
||||
<i.btn.icon.download
|
||||
class='${torrentSummary.status}'
|
||||
title='${downloadTooltip}'
|
||||
class=${torrentSummary.status}
|
||||
title=${downloadTooltip}
|
||||
onclick=${(e) => handleButton('toggleTorrent', e)}>
|
||||
${downloadIcon}
|
||||
</i>
|
||||
@@ -153,6 +155,7 @@ function TorrentList (state, dispatch) {
|
||||
function handleButton (action, e) {
|
||||
// Prevent propagation so that we don't select/unselect the torrent
|
||||
e.stopPropagation()
|
||||
if (e.target.classList.contains('disabled')) return
|
||||
dispatch(action, torrentSummary)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user