diff --git a/src/main/ipc.js b/src/main/ipc.js index ec6f4b1b8d..0ba4cad774 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -64,6 +64,10 @@ function init () { thumbar.enable() }) + ipc.on('onPlayerUpdate', function (e, ...args) { + menu.onPlayerUpdate(...args) + }) + ipc.on('onPlayerClose', function () { menu.setPlayerOpen(false) powerSaveBlocker.disable() diff --git a/src/main/menu.js b/src/main/menu.js index 74067fa349..2e4d6373f5 100644 --- a/src/main/menu.js +++ b/src/main/menu.js @@ -3,6 +3,7 @@ module.exports = { setPlayerOpen, setWindowFocus, setAllowNav, + onPlayerUpdate, onToggleAlwaysOnTop, onToggleFullScreen } @@ -25,6 +26,8 @@ function init () { function setPlayerOpen (flag) { getMenuItem('Play/Pause').enabled = flag + getMenuItem('Skip Next').enabled = flag + getMenuItem('Skip Previous').enabled = flag getMenuItem('Increase Volume').enabled = flag getMenuItem('Decrease Volume').enabled = flag getMenuItem('Step Forward').enabled = flag @@ -32,6 +35,16 @@ function setPlayerOpen (flag) { getMenuItem('Increase Speed').enabled = flag getMenuItem('Decrease Speed').enabled = flag getMenuItem('Add Subtitles File...').enabled = flag + + if (flag === false) { + getMenuItem('Skip Next').enabled = false + getMenuItem('Skip Previous').enabled = false + } +} + +function onPlayerUpdate (hasNext, hasPrevious) { + getMenuItem('Skip Next').enabled = hasNext + getMenuItem('Skip Previous').enabled = hasPrevious } function setWindowFocus (flag) { @@ -187,6 +200,21 @@ function getMenuTemplate () { { type: 'separator' }, + { + label: 'Skip Next', + accelerator: 'N', + click: () => windows.main.dispatch('nextTrack'), + enabled: false + }, + { + label: 'Skip Previous', + accelerator: 'P', + click: () => windows.main.dispatch('previousTrack'), + enabled: false + }, + { + type: 'separator' + }, { label: 'Increase Volume', accelerator: 'CmdOrCtrl+Up', diff --git a/src/main/shortcuts.js b/src/main/shortcuts.js index 02bc2320de..9fd71e8a70 100644 --- a/src/main/shortcuts.js +++ b/src/main/shortcuts.js @@ -12,9 +12,19 @@ function enable () { 'MediaPlayPause', () => windows.main.dispatch('playPause') ) + electron.globalShortcut.register( + 'MediaNextTrack', + () => windows.main.dispatch('nextTrack') + ) + electron.globalShortcut.register( + 'MediaPreviousTrack', + () => windows.main.dispatch('previousTrack') + ) } function disable () { // Return the media key to the OS, so other apps can use it. electron.globalShortcut.unregister('MediaPlayPause') + electron.globalShortcut.unregister('MediaNextTrack') + electron.globalShortcut.unregister('MediaPreviousTrack') } diff --git a/src/renderer/controllers/media-controller.js b/src/renderer/controllers/media-controller.js index a3821683eb..a68ac90153 100644 --- a/src/renderer/controllers/media-controller.js +++ b/src/renderer/controllers/media-controller.js @@ -44,7 +44,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 = state.server.localURL + '/' + state.playlist.getCurrent().fileIndex + ipcRenderer.send('openExternalPlayer', state.saved.prefs.externalPlayerPath, mediaURL, state.window.title) state.playing.location = 'external' } diff --git a/src/renderer/controllers/playback-controller.js b/src/renderer/controllers/playback-controller.js index d1c38153d2..b7880fe6a0 100644 --- a/src/renderer/controllers/playback-controller.js +++ b/src/renderer/controllers/playback-controller.js @@ -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 @@ -26,16 +27,37 @@ module.exports = class PlaybackController { // * Stream, if not already fully downloaded // * If no file index is provided, pick the default file to play 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.play() + state.playlist.jumpToFile(infoHash, index) + this.updatePlayer(false, callback) + } else { + var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) + var playlist = new Playlist(torrentSummary) + + // 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) + if (index === undefined) return this.onError(new errors.UnplayableError()) + + playlist.jumpToFile(infoHash, index) + + state.location.go({ + url: 'player', + setup: (cb) => { + this.play() + this.openPlayer(playlist, cb) + }, + destroy: () => this.closePlayer() + }, (err) => { + if (err) dispatch('error', err) + }) + } + + function callback (err) { + if (err) this.onError(err) + } } // Open a file in OS default app. @@ -64,6 +86,30 @@ module.exports = class PlaybackController { else this.pause() } + // Play next file in list (if any) + nextTrack () { + var state = this.state + if (state.playlist && state.playlist.hasNext()) { + state.playlist.next() + this.updatePlayer(false, (err) => { + if (err) this.onError(err) + else this.play() + }) + } + } + + // Play previous track in list (if any) + previousTrack () { + var state = this.state + if (state.playlist && state.playlist.hasPrevious()) { + state.playlist.previous() + this.updatePlayer(false, (err) => { + if (err) this.onError(err) + else this.play() + }) + } + } + // Play (unpause) the current media play () { var state = this.state @@ -167,14 +213,16 @@ module.exports = class PlaybackController { return false } - // Opens the video player to a specific torrent - openPlayer (infoHash, index, cb) { - var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) + // Opens the video player to a specific playlist + openPlayer (playlist, cb) { + var state = this.state + state.playlist = playlist + + var track = playlist.getCurrent() + if (track === undefined) return cb(new errors.UnplayableError()) - // 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) - if (index === undefined) return cb(new errors.UnplayableError()) + var torrentSummary = TorrentSummary.getByKey(state, state.playlist.getInfoHash()) + state.playing.infoHash = torrentSummary.infoHash // update UI to show pending playback if (torrentSummary.progress !== 1) sound.play('PLAY') @@ -191,38 +239,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(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) { - var fileSummary = torrentSummary.files[index] + // Called each time the playlist state changes + updatePlayer (resume, cb) { + var state = this.state + var track = state.playlist.getCurrent() + + if (track === undefined) { + return cb(new Error('Can\'t play that file')) + } + + var torrentSummary = TorrentSummary.getByKey(this.state, state.playlist.getInfoHash()) + var fileSummary = torrentSummary.files[track.fileIndex] // 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' + state.playing.fileIndex = track.fileIndex + state.playing.type = track.type // 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) { - ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) + ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, track.fileIndex) } // if it's video, check for subtitles files that are done downloading @@ -233,34 +311,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', state.playlist.hasNext(), state.playlist.hasPrevious()) + cb() } closePlayer () { @@ -287,6 +352,7 @@ module.exports = class PlaybackController { // Reset the window contents back to the home screen state.playing = State.getDefaultPlayState() + state.playlist = null state.server = null // Reset the window size and location back to where it was diff --git a/src/renderer/lib/cast.js b/src/renderer/lib/cast.js index 180c833e00..8fdf6884ee 100644 --- a/src/renderer/lib/cast.js +++ b/src/renderer/lib/cast.js @@ -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 diff --git a/src/renderer/lib/errors.js b/src/renderer/lib/errors.js index 2bdfdfe605..a69c1ead9a 100644 --- a/src/renderer/lib/errors.js +++ b/src/renderer/lib/errors.js @@ -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 diff --git a/src/renderer/lib/playlist.js b/src/renderer/lib/playlist.js new file mode 100644 index 0000000000..dfc97c31b9 --- /dev/null +++ b/src/renderer/lib/playlist.js @@ -0,0 +1,78 @@ +var TorrentPlayer = require('./torrent-player') + +module.exports = Playlist + +function Playlist (torrentSummary) { + this._infoHash = torrentSummary.infoHash + this._position = 0 + this._tracks = extractTracks(torrentSummary) +} + +Playlist.prototype.getInfoHash = function () { + return this._infoHash +} + +Playlist.prototype.getTracks = function () { + return this._tracks +} + +Playlist.prototype.hasNext = function () { + return this._position + 1 < this._tracks.length +} + +Playlist.prototype.hasPrevious = function () { + return this._position > 0 +} + +Playlist.prototype.next = function () { + if (this.hasNext()) { + this._position++ + return this.getCurrent() + } +} + +Playlist.prototype.previous = function () { + if (this.hasPrevious()) { + this._position-- + return this.getCurrent() + } +} + +Playlist.prototype.jumpToFile = function (infoHash, fileIndex) { + this.setPosition(this._tracks.findIndex( + (track) => track.infoHash === infoHash && track.fileIndex === fileIndex + )) + return this.getCurrent() +} + +Playlist.prototype.getCurrent = function () { + var position = this.getPosition() + return position === undefined ? undefined : this._tracks[position] +} + +Playlist.prototype.getPosition = function () { + if (this._position >= 0 && this._position < this._tracks.length) { + return this._position + } else return undefined +} + +Playlist.prototype.setPosition = function (position) { + this._position = position +} + +function extractTracks (torrentSummary) { + return torrentSummary.files.map((file, index) => ({ file, index })) + .filter((object) => TorrentPlayer.isPlayable(object.file)) + .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) => ({ + infoHash: torrentSummary.infoHash, + fileIndex: object.index, + type: TorrentPlayer.isVideo(object.file) ? 'video' + : TorrentPlayer.isAudio(object.file) ? 'audio' + : 'other' + })) +} diff --git a/src/renderer/lib/state.js b/src/renderer/lib/state.js index d366f61965..53bfecb592 100644 --- a/src/renderer/lib/state.js +++ b/src/renderer/lib/state.js @@ -39,6 +39,7 @@ function getDefaultState () { selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */ playing: getDefaultPlayState(), /* the media (audio or video) that we're currently playing */ devices: {}, /* playback devices like Chromecast and AppleTV */ + playlist: null, dock: { badge: 0, progress: 0 diff --git a/src/renderer/main.js b/src/renderer/main.js index e9ed6cee96..a7a92c0454 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -188,6 +188,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), diff --git a/src/renderer/pages/PlayerPage.js b/src/renderer/pages/PlayerPage.js index 44db7c81b9..67c54d54a0 100644 --- a/src/renderer/pages/PlayerPage.js +++ b/src/renderer/pages/PlayerPage.js @@ -109,7 +109,7 @@ function renderMedia (state) { var MediaTagName = state.playing.type var mediaTag = ( = 0 ? 'active' : '' + var prevClass = state.playlist.hasPrevious() ? '' : 'disabled' + var nextClass = state.playlist.hasNext() ? '' : 'disabled' var elements = [
@@ -397,6 +403,13 @@ function renderPlayerControls (state) { />
, + + skip_previous + , + , + + skip_next + , + 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) }) } diff --git a/static/main.css b/static/main.css index bb9e5cf6ed..ae5c6aac8b 100644 --- a/static/main.css +++ b/static/main.css @@ -629,7 +629,25 @@ body.drag .app::after { opacity: 1; } -.player .controls .play-pause { +.player .controls .icon.disabled { + opacity: 0.3; +} + +.player .controls .icon.skip-previous { + font-size: 28px; + margin-top: 5px; + margin-right: 10px; + margin-left: 15px; +} + +.player .controls .icon.play-pause { + font-size: 28px; + margin-top: 5px; + margin-right: 10px; + margin-left: 15px; +} + +.player .controls .icon.skip-next { font-size: 28px; margin-top: 5px; margin-right: 10px;