Add playlists feature (#871)

* Open multi-file torrents as playlists

* Add `repeat` and `shuffle` options to the player

* Autoplay first file in torrent

* replaces `pickFileToPlay` feature
* when reopening player, restores the most recently viewed file

* Add playlist navigation buttons to Windows thumbar

* Remove `repeat` and `shuffle` options

This reverts commit 9284122461.

* Play files in order they appear in torrent

* Clean up playlists code
This commit is contained in:
Adam Gotlib
2016-09-02 04:18:48 +02:00
committed by DC
parent 14102ab3e6
commit 30732305ff
18 changed files with 363 additions and 117 deletions

View File

@@ -2,6 +2,8 @@ const electron = require('electron')
const ipcRenderer = electron.ipcRenderer
const Playlist = require('../lib/playlist')
// Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc)
module.exports = class MediaController {
@@ -44,7 +46,8 @@ module.exports = class MediaController {
openExternalPlayer () {
var state = this.state
ipcRenderer.send('openExternalPlayer', state.saved.prefs.externalPlayerPath, state.server.localURL, state.window.title)
var mediaURL = Playlist.getCurrentLocalURL(this.state)
ipcRenderer.send('openExternalPlayer', state.saved.prefs.externalPlayerPath, mediaURL, state.window.title)
state.playing.location = 'external'
}

View File

@@ -8,6 +8,7 @@ const errors = require('../lib/errors')
const sound = require('../lib/sound')
const TorrentPlayer = require('../lib/torrent-player')
const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const State = require('../lib/state')
const ipcRenderer = electron.ipcRenderer
@@ -24,18 +25,29 @@ module.exports = class PlaybackController {
// Play a file in a torrent.
// * Start torrenting, if necessary
// * Stream, if not already fully downloaded
// * If no file index is provided, pick the default file to play
// * If no file index is provided, restore the most recently viewed file or autoplay the first
playFile (infoHash, index /* optional */) {
this.state.location.go({
url: 'player',
setup: (cb) => {
this.play()
this.openPlayer(infoHash, index, cb)
},
destroy: () => this.closePlayer()
}, (err) => {
if (err) dispatch('error', err)
})
var state = this.state
if (state.location.url() === 'player') {
this.updatePlayer(infoHash, index, false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
} else {
state.location.go({
url: 'player',
setup: (cb) => {
this.play()
this.openPlayer(infoHash, index, (err) => {
if (!err) this.play
cb(err)
})
},
destroy: () => this.closePlayer()
}, (err) => {
if (err) dispatch('error', err)
})
}
}
// Open a file in OS default app.
@@ -64,6 +76,30 @@ module.exports = class PlaybackController {
else this.pause()
}
// Play next file in list (if any)
nextTrack () {
var state = this.state
if (Playlist.hasNext(state)) {
this.updatePlayer(
state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
}
}
// Play previous track in list (if any)
previousTrack () {
var state = this.state
if (Playlist.hasPrevious(state)) {
this.updatePlayer(
state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
}
}
// Play (unpause) the current media
play () {
var state = this.state
@@ -169,13 +205,16 @@ module.exports = class PlaybackController {
// Opens the video player to a specific torrent
openPlayer (infoHash, index, cb) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
var state = this.state
// automatically choose which file in the torrent to play, if necessary
if (index === undefined) index = torrentSummary.defaultPlayFileIndex
if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files)
var torrentSummary = TorrentSummary.getByKey(state, infoHash)
if (index === undefined) index = torrentSummary.mostRecentFileIndex
if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable)
if (index === undefined) return cb(new errors.UnplayableError())
state.playing.infoHash = torrentSummary.infoHash
// update UI to show pending playback
if (torrentSummary.progress !== 1) sound.play('PLAY')
// TODO: remove torrentSummary.playStatus
@@ -191,34 +230,68 @@ module.exports = class PlaybackController {
this.update()
}, 10000) /* give it a few seconds */
this.startServer(torrentSummary, () => {
clearTimeout(timeout)
// if we timed out (user clicked play a long time ago), don't autoplay
var timedOut = torrentSummary.playStatus === 'timeout'
delete torrentSummary.playStatus
if (timedOut) {
ipcRenderer.send('wt-stop-server')
return this.update()
}
ipcRenderer.send('onPlayerOpen')
this.updatePlayer(infoHash, index, true, cb)
})
}
// Starts WebTorrent server for media streaming
startServer (torrentSummary, cb) {
if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary.torrentKey)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb))
() => onTorrentReady())
} else {
this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)
onTorrentReady()
}
function onTorrentReady () {
ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, () => cb())
}
}
openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
// Called each time the current file changes
updatePlayer (infoHash, index, resume, cb) {
var state = this.state
var torrentSummary = TorrentSummary.getByKey(state, infoHash)
var fileSummary = torrentSummary.files[index]
if (!TorrentPlayer.isPlayable(fileSummary)) {
torrentSummary.mostRecentFileIndex = undefined
return cb(new Error('Can\'t play that file'))
}
torrentSummary.mostRecentFileIndex = index
// update state
var state = this.state
state.playing.infoHash = torrentSummary.infoHash
state.playing.fileIndex = index
state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video'
: TorrentPlayer.isAudio(fileSummary) ? 'audio'
: 'other'
// pick up where we left off
if (fileSummary.currentTime) {
var jumpToTime = 0
if (resume && fileSummary.currentTime) {
var fraction = fileSummary.currentTime / fileSummary.duration
var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime
jumpToTime = fileSummary.currentTime
}
}
state.playing.jumpToTime = jumpToTime
// if it's audio, parse out the metadata (artist, title, etc)
if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
@@ -233,34 +306,21 @@ module.exports = class PlaybackController {
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
}
ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index)
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => {
clearTimeout(timeout)
state.window.title = fileSummary.name
// if we timed out (user clicked play a long time ago), don't autoplay
var timedOut = torrentSummary.playStatus === 'timeout'
delete torrentSummary.playStatus
if (timedOut) {
ipcRenderer.send('wt-stop-server')
return this.update()
}
state.window.title = torrentSummary.files[state.playing.fileIndex].name
// play in VLC if set as default player (Preferences / Playback / Play in VLC)
if (this.state.saved.prefs.openExternalPlayer) {
dispatch('openExternalPlayer')
this.update()
cb()
return
}
// otherwise, play the video
// play in VLC if set as default player (Preferences / Playback / Play in VLC)
if (this.state.saved.prefs.openExternalPlayer) {
dispatch('openExternalPlayer')
this.update()
ipcRenderer.send('onPlayerOpen')
cb()
})
return
}
// otherwise, play the video
this.update()
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
cb()
}
closePlayer () {

View File

@@ -2,7 +2,6 @@ const path = require('path')
const ipcRenderer = require('electron').ipcRenderer
const TorrentSummary = require('../lib/torrent-summary')
const TorrentPlayer = require('../lib/torrent-player')
const sound = require('../lib/sound')
const {dispatch} = require('../lib/dispatcher')
@@ -73,7 +72,6 @@ module.exports = class TorrentController {
if (!torrentSummary.selections) {
torrentSummary.selections = torrentSummary.files.map((x) => true)
}
torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files)
dispatch('update')
// Save the .torrent file, if it hasn't been saved already

View File

@@ -96,7 +96,7 @@ function chromecastPlayer () {
function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name
}, function (err) {
@@ -183,7 +183,7 @@ function airplayPlayer () {
}
function open () {
ret.device.play(state.server.networkURL, function (err, res) {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, function (err, res) {
if (err) {
state.playing.location = 'local'
state.errors.push({
@@ -275,7 +275,7 @@ function dlnaPlayer (player) {
function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name,
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0

View File

@@ -1,8 +1,15 @@
module.exports = {
UnplayableError
UnplayableTorrentError,
UnplayableFileError
}
function UnplayableError () {
function UnplayableTorrentError () {
this.message = 'Can\'t play any files in torrent'
}
UnplayableError.prototype = Error
function UnplayableFileError () {
this.message = 'Can\'t play that file'
}
UnplayableTorrentError.prototype = Error
UnplayableFileError.prototype = Error

View File

@@ -0,0 +1,85 @@
module.exports = {
hasNext,
getNextIndex,
hasPrevious,
getPreviousIndex,
getCurrentLocalURL
}
const TorrentSummary = require('./torrent-summary')
const TorrentPlayer = require('./torrent-player')
var cache = {
infoHash: null,
previousIndex: null,
currentIndex: null,
nextIndex: null
}
function hasNext (state) {
updateCache(state)
return cache.nextIndex !== null
}
function getNextIndex (state) {
updateCache(state)
return cache.nextIndex
}
function hasPrevious (state) {
updateCache(state)
return cache.previousIndex !== null
}
function getPreviousIndex (state) {
updateCache(state)
return cache.previousIndex
}
function getCurrentLocalURL (state) {
return state.server.localURL + '/' + state.playing.fileIndex
}
function updateCache (state) {
var infoHash = state.playing.infoHash
var fileIndex = state.playing.fileIndex
if (infoHash === cache.infoHash) {
switch (fileIndex) {
case cache.currentIndex:
return
case cache.nextIndex:
cache.previousIndex = cache.currentIndex
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
return
case cache.previousIndex:
cache.previousIndex = findPreviousIndex(state)
cache.nextIndex = cache.currentIndex
cache.currentIndex = fileIndex
return
}
} else {
cache.infoHash = infoHash
}
cache.previousIndex = findPreviousIndex(state)
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
}
function findPreviousIndex (state) {
var files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (var i = state.playing.fileIndex - 1; i >= 0; i--) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}
function findNextIndex (state) {
var files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (var i = state.playing.fileIndex + 1; i < files.length; i++) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}

View File

@@ -3,8 +3,7 @@ module.exports = {
isVideo,
isAudio,
isTorrent,
isPlayableTorrentSummary,
pickFileToPlay
isPlayableTorrentSummary
}
var path = require('path')
@@ -60,25 +59,3 @@ function getFileExtension (file) {
function isPlayableTorrentSummary (torrentSummary) {
return torrentSummary.files && torrentSummary.files.some(isPlayable)
}
// 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(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(isAudio)
if (audioFiles.length > 0) {
return files.indexOf(audioFiles[0])
}
// no video or audio means nothing is playable
return undefined
}

View File

@@ -189,6 +189,8 @@ const dispatchHandlers = {
// Playback
'playFile': (infoHash, index) => controllers.playback.playFile(infoHash, index),
'playPause': () => controllers.playback.playPause(),
'nextTrack': () => controllers.playback.nextTrack(),
'previousTrack': () => controllers.playback.previousTrack(),
'skip': (time) => controllers.playback.skip(time),
'skipTo': (time) => controllers.playback.skipTo(time),
'changePlaybackRate': (dir) => controllers.playback.changePlaybackRate(dir),

View File

@@ -5,6 +5,7 @@ const zeroFill = require('zero-fill')
const path = require('path')
const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const {dispatch, dispatcher} = require('../lib/dispatcher')
// Shows a streaming video player. Standard features + Chromecast + Airplay
@@ -109,7 +110,7 @@ function renderMedia (state) {
var MediaTagName = state.playing.type
var mediaTag = (
<MediaTagName
src={state.server.localURL}
src={Playlist.getCurrentLocalURL(state)}
onDoubleClick={dispatcher('toggleFullScreen')}
onLoadedMetadata={onLoadedMetadata}
onEnded={onEnded}
@@ -144,9 +145,13 @@ function renderMedia (state) {
dispatch('setDimensions', dimensions)
}
// When the video completes, pause the video instead of looping
function onEnded (e) {
state.playing.isPaused = true
if (Playlist.hasNext(state)) {
dispatch('nextTrack')
} else {
// When the last video completes, pause the video instead of looping
state.playing.isPaused = true
}
}
function onCanPlay (e) {
@@ -378,6 +383,8 @@ function renderPlayerControls (state) {
: state.playing.subtitles.selectedIndex >= 0
? 'active'
: ''
var prevClass = Playlist.hasPrevious(state) ? '' : 'disabled'
var nextClass = Playlist.hasNext(state) ? '' : 'disabled'
var elements = [
<div key='playback-bar' className='playback-bar'>
@@ -397,6 +404,13 @@ function renderPlayerControls (state) {
/>
</div>,
<i
key='skip-previous'
className={'icon skip-previous float-left ' + prevClass}
onClick={dispatcher('previousTrack')}>
skip_previous
</i>,
<i
key='play'
className='icon play-pause float-left'
@@ -404,6 +418,13 @@ function renderPlayerControls (state) {
{state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>,
<i
key='skip-next'
className={'icon skip-next float-left ' + nextClass}
onClick={dispatcher('nextTrack')}>
skip_next
</i>,
<i
key='fullscreen'
className='icon fullscreen float-right'

View File

@@ -207,10 +207,10 @@ module.exports = class TorrentList extends React.Component {
// Do we have a saved position? Show it using a radial progress bar on top
// of the play button, unless already showing a spinner there:
var willShowSpinner = torrentSummary.playStatus === 'requested'
var defaultFile = torrentSummary.files &&
torrentSummary.files[torrentSummary.defaultPlayFileIndex]
if (defaultFile && defaultFile.currentTime && !willShowSpinner) {
var fraction = defaultFile.currentTime / defaultFile.duration
var mostRecentFile = torrentSummary.files &&
torrentSummary.files[torrentSummary.mostRecentFileIndex]
if (mostRecentFile && mostRecentFile.currentTime && !willShowSpinner) {
var fraction = mostRecentFile.currentTime / mostRecentFile.duration
positionElem = this.renderRadialProgressBar(fraction, 'radial-progress-large')
playClass = 'resume-position'
}
@@ -273,11 +273,6 @@ module.exports = class TorrentList extends React.Component {
var fileRows = torrentSummary.files
.filter((file) => !file.path.includes('/.____padding_file/'))
.map((file, index) => ({ file, index }))
.sort(function (a, b) {
if (a.file.name < b.file.name) return -1
if (b.file.name < a.file.name) return 1
return 0
})
.map((object) => this.renderFileRow(torrentSummary, object.file, object.index))
filesElement = (

View File

@@ -83,8 +83,8 @@ function init () {
generateTorrentPoster(torrentKey))
ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
getAudioMetadata(infoHash, index))
ipc.on('wt-start-server', (e, infoHash, index) =>
startServer(infoHash, index))
ipc.on('wt-start-server', (e, infoHash) =>
startServer(infoHash))
ipc.on('wt-stop-server', (e) =>
stopServer())
ipc.on('wt-select-files', (e, infoHash, selections) =>
@@ -301,20 +301,20 @@ function getTorrentProgress () {
}
}
function startServer (infoHash, index) {
function startServer (infoHash) {
var torrent = client.get(infoHash)
if (torrent.ready) startServerFromReadyTorrent(torrent, index)
else torrent.once('ready', () => startServerFromReadyTorrent(torrent, index))
if (torrent.ready) startServerFromReadyTorrent(torrent)
else torrent.once('ready', () => startServerFromReadyTorrent(torrent))
}
function startServerFromReadyTorrent (torrent, index, cb) {
function startServerFromReadyTorrent (torrent, cb) {
if (server) return
// start the streaming torrent-to-http server
server = torrent.createServer()
server.listen(0, function () {
var port = server.address().port
var urlSuffix = ':' + port + '/' + index
var urlSuffix = ':' + port
var info = {
torrentKey: torrent.key,
localURL: 'http://localhost' + urlSuffix,
@@ -322,7 +322,7 @@ function startServerFromReadyTorrent (torrent, index, cb) {
}
ipc.send('wt-server-running', info)
ipc.send('wt-server-' + torrent.infoHash, info) // TODO: hack
ipc.send('wt-server-' + torrent.infoHash, info)
})
}