Refactor main.js: playback and subtitles controllers

This commit is contained in:
DC
2016-07-01 17:11:46 -07:00
parent f85e0a61b1
commit 24f5856649
7 changed files with 497 additions and 427 deletions

View File

@@ -0,0 +1,299 @@
const electron = require('electron')
const path = require('path')
const Cast = require('../lib/cast')
const {dispatch} = require('../lib/dispatcher')
const telemetry = require('../lib/telemetry')
const errors = require('../lib/errors')
const sound = require('../lib/sound')
const TorrentPlayer = require('../lib/torrent-player')
const TorrentSummary = require('../lib/torrent-summary')
const State = require('../lib/state')
const ipcRenderer = electron.ipcRenderer
// Controls playback of torrents and files within torrents
// both local (<video>,<audio>,VLC) and remote (cast)
module.exports = class PlaybackController {
constructor (state, config, update) {
this.state = state
this.config = config
this.update = update
}
// 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
playFile (infoHash, index /* optional */) {
this.state.location.go({
url: 'player',
onbeforeload: (cb) => {
this.play()
openPlayer(this.state, infoHash, index, cb)
},
onbeforeunload: (cb) => closePlayer(this.state, this.config, cb)
}, (err) => {
if (err) dispatch('error', err)
})
}
// Show a file in the OS, eg in Finder on a Mac
openItem (infoHash, index) {
var torrentSummary = torrentSummary.getByKey(this.state, infoHash)
var filePath = path.join(
torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
}
// Toggle (play or pause) the currently playing media
playPause () {
var state = this.state
if (state.location.url() !== 'player') return
// force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately
if (!state.window.isVisible) {
var mediaTag = document.querySelector('video,audio')
if (state.playing.isPaused) mediaTag.play()
else mediaTag.pause()
}
if (state.playing.isPaused) this.play()
else this.pause()
}
// Play (unpause) the current media
play () {
var state = this.state
if (!state.playing.isPaused) return
state.playing.isPaused = false
if (isCasting(state)) {
Cast.play()
}
ipcRenderer.send('onPlayerPlay')
}
// Pause the currently playing media
pause () {
var state = this.state
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting(state)) {
Cast.pause()
}
ipcRenderer.send('onPlayerPause')
}
// Skip (aka seek) to a specific point, in seconds
skipTo (time) {
if (isCasting(this.state)) Cast.seek(time)
else this.state.playing.jumpToTime = time
}
// Change playback speed. 1 = faster, -1 = slower
// Playback speed ranges from 16 (fast forward) to 1 (normal playback)
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
// until -16 (fast rewind)
changePlaybackRate (direction) {
var state = this.state
var rate = state.playing.playbackRate
if (direction > 0 && rate >= 0.25 && rate < 2) {
rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25
} else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */
rate = -1
} else if (direction > 0 && rate === -1) {
rate = 0.25
} else if ((direction > 0 && rate >= 1 && rate < 16) || (direction < 0 && rate > -16 && rate <= -1)) {
rate *= 2
} else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) {
rate /= 2
}
state.playing.playbackRate = rate
if (isCasting(state) && !Cast.setRate(rate)) {
state.playing.playbackRate = 1
}
}
// Change the volume, in range [0, 1], by some amount
// For example, volume muted (0), changeVolume (0.3) increases to 30% volume
changeVolume (delta) {
// change volume with delta value
this.setVolume(this.state.playing.volume + delta)
}
// Set the volume to some value in [0, 1]
setVolume (volume) {
// check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume))
var state = this.state
if (isCasting(state)) {
Cast.setVolume(volume)
} else {
state.playing.setVolume = volume
}
}
// Hide player controls while playing video, if the mouse stays still for a while
// 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
showOrHidePlayerControls () {
var state = this.state
var hideControls = state.location.url() === 'player' &&
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local'
if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls
return true
}
return false
}
}
// Opens the video player to a specific torrent
function openPlayer (state, infoHash, index, cb) {
var torrentSummary = TorrentSummary.getByKey(state, infoHash)
// 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())
// update UI to show pending playback
if (torrentSummary.progress !== 1) sound.play('PLAY')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'requested'
this.update()
var timeout = setTimeout(() => {
telemetry.logPlayAttempt('timeout')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR')
cb(new Error('Playback timed out. Try again.'))
this.update()
}, 10000) /* give it a few seconds */
if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb))
} else {
openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb)
}
}
function openPlayerFromActiveTorrent (state, torrentSummary, index, timeout, cb) {
var fileSummary = torrentSummary.files[index]
// update 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 fraction = fileSummary.currentTime / fileSummary.duration
var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime
}
}
// 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)
}
// if it's video, check for subtitles files that are done downloading
dispatch('checkForSubtitles')
ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index)
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => {
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()
}
// otherwise, play the video
state.window.title = torrentSummary.files[state.playing.fileIndex].name
this.update()
ipcRenderer.send('onPlayerOpen')
cb()
})
}
function closePlayer (state, config, cb) {
console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC
if (isCasting(state)) {
Cast.stop()
}
if (state.playing.location === 'vlc') {
ipcRenderer.send('vlcQuit')
}
// Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume
// Telemetry: track what happens after the user clicks play
var result = state.playing.result // 'success' or 'error'
if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed
else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc
else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen
state.window.title = config.APP_WINDOW_TITLE
state.playing = State.getDefaultPlayState()
state.server = null
// Reset the window size and location back to where it was
if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false)
}
restoreBounds(state)
// Tell the WebTorrent process to kill the torrent-to-HTTP server
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('onPlayerClose')
this.update()
cb()
}
// 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 (state) {
return state.playing.location === 'chromecast' ||
state.playing.location === 'airplay' ||
state.playing.location === 'dlna'
}
function restoreBounds (state) {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}

View File

@@ -0,0 +1,134 @@
const electron = require('electron')
const fs = require('fs-extra')
const path = require('path')
const parallel = require('run-parallel')
const {dispatch} = require('../lib/dispatcher')
module.exports = class SubtitlesController {
constructor (state) {
this.state = state
}
openSubtitles () {
electron.remote.dialog.showOpenDialog({
title: 'Select a subtitles file.',
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
properties: [ 'openFile' ]
}, (filenames) => {
if (!Array.isArray(filenames)) return
this.addSubtitles(filenames, true)
})
}
selectSubtitle (ix) {
this.state.playing.subtitles.selectedIndex = ix
}
toggleSubtitlesMenu () {
var subtitles = this.state.playing.subtitles
subtitles.showMenu = !subtitles.showMenu
}
addSubtitles (files, autoSelect) {
// Subtitles are only supported when playing video files
if (this.state.playing.type !== 'video') return
if (files.length === 0) return
var subtitles = this.state.playing.subtitles
// Read the files concurrently, then add all resulting subtitle tracks
var tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
parallel(tasks, function (err, tracks) {
if (err) return dispatch('error', err)
for (var i = 0; i < tracks.length; i++) {
// No dupes allowed
var track = tracks[i]
if (subtitles.tracks.some(
(t) => track.filePath === t.filePath)) continue
// Add the track
subtitles.tracks.push(track)
// If we're auto-selecting a track, try to find one in the user's language
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
subtitles.selectedIndex = subtitles.tracks.length - 1
}
}
// Finally, make sure no two tracks have the same label
relabelSubtitles(subtitles)
})
}
checkForSubtitles () {
if (this.state.playing.type !== 'video') return
var torrentSummary = this.state.getPlayingTorrentSummary()
if (!torrentSummary || !torrentSummary.progress) return
torrentSummary.progress.files.forEach((fp, ix) => {
if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files
var file = torrentSummary.files[ix]
if (!this.isSubtitle(file.name)) return
var filePath = path.join(torrentSummary.path, file.path)
this.addSubtitles([filePath], false)
})
}
isSubtitle (file) {
var name = typeof file === 'string' ? file : file.name
var ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt'
}
}
function loadSubtitle (file, cb) {
// Lazy load to keep startup fast
var concat = require('simple-concat')
var LanguageDetect = require('languagedetect')
var srtToVtt = require('srt-to-vtt')
// Read the .SRT or .VTT file, parse it, add subtitle track
var filePath = file.path || file
var vttStream = fs.createReadStream(filePath).pipe(srtToVtt())
concat(vttStream, function (err, buf) {
if (err) return dispatch('error', 'Can\'t parse subtitles file.')
// Detect what language the subtitles are in
var vttContents = buf.toString().replace(/(.*-->.*)/g, '')
var langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected,
label: langDetected,
filePath: filePath
}
cb(null, track)
})
}
// Checks whether a language name like "English" or "German" matches the system
// language, aka the current locale
function isSystemLanguage (language) {
var iso639 = require('iso-639-1')
var osLangISO = window.navigator.language.split('-')[0] // eg "en"
var langIso = iso639.getCode(language) // eg "de" if language is "German"
return langIso === osLangISO
}
// Make sure we don't have two subtitle tracks with the same label
// Labels each track by language, eg "German", "English", "English 2", ...
function relabelSubtitles (subtitles) {
var counts = {}
subtitles.tracks.forEach(function (track) {
var lang = track.language
counts[lang] = (counts[lang] || 0) + 1
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang
})
}

View File

@@ -2,7 +2,8 @@ module.exports = {
isPlayable, isPlayable,
isVideo, isVideo,
isAudio, isAudio,
isPlayableTorrent isPlayableTorrent,
pickFileToPlay
} }
var path = require('path') var path = require('path')
@@ -43,3 +44,25 @@ function isAudio (file) {
function isPlayableTorrent (torrentSummary) { function isPlayableTorrent (torrentSummary) {
return torrentSummary.files && torrentSummary.files.some(isPlayable) 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

@@ -5,9 +5,7 @@ crashReporter.init()
const dragDrop = require('drag-drop') const dragDrop = require('drag-drop')
const electron = require('electron') const electron = require('electron')
const fs = require('fs-extra')
const mainLoop = require('main-loop') const mainLoop = require('main-loop')
const parallel = require('run-parallel')
const path = require('path') const path = require('path')
const createElement = require('virtual-dom/create-element') const createElement = require('virtual-dom/create-element')
@@ -17,7 +15,6 @@ const patch = require('virtual-dom/patch')
const config = require('../config') const config = require('../config')
const App = require('./views/app') const App = require('./views/app')
const telemetry = require('./lib/telemetry') const telemetry = require('./lib/telemetry')
const errors = require('./lib/errors')
const sound = require('./lib/sound') const sound = require('./lib/sound')
const State = require('./lib/state') const State = require('./lib/state')
const TorrentPlayer = require('./lib/torrent-player') const TorrentPlayer = require('./lib/torrent-player')
@@ -27,6 +24,8 @@ const MediaController = require('./controllers/media-controller')
const UpdateController = require('./controllers/update-controller') const UpdateController = require('./controllers/update-controller')
const PrefsController = require('./controllers/prefs-controller') const PrefsController = require('./controllers/prefs-controller')
const TorrentListController = require('./controllers/torrent-list-controller') const TorrentListController = require('./controllers/torrent-list-controller')
const PlaybackController = require('./controllers/playback-controller')
const SubtitlesController = require('./controllers/subtitles-controller')
// Yo-yo pattern: state object lives here and percolates down thru all the views. // Yo-yo pattern: state object lives here and percolates down thru all the views.
// Events come back up from the views via dispatch(...) // Events come back up from the views via dispatch(...)
@@ -61,7 +60,9 @@ function onState (err, _state) {
media: new MediaController(state), media: new MediaController(state),
update: new UpdateController(state), update: new UpdateController(state),
prefs: new PrefsController(state, config), prefs: new PrefsController(state, config),
torrentList: new TorrentListController(state) torrentList: new TorrentListController(state),
playback: new PlaybackController(state, config, update),
subtitles: new SubtitlesController(state)
} }
// Add first page to location history // Add first page to location history
@@ -143,7 +144,7 @@ function render (state) {
// Calls render() to go from state -> UI, then applies to vdom to the real DOM. // Calls render() to go from state -> UI, then applies to vdom to the real DOM.
function update () { function update () {
showOrHidePlayerControls() controllers.playback.showOrHidePlayerControls()
vdomLoop.update(state) vdomLoop.update(state)
updateElectron() updateElectron()
} }
@@ -206,41 +207,48 @@ function dispatch (action, ...args) {
if (action === 'openTorrentContextMenu') { if (action === 'openTorrentContextMenu') {
controllers.torrentList.openTorrentContextMenu(args[0] /* infoHash */) controllers.torrentList.openTorrentContextMenu(args[0] /* infoHash */)
} }
if (action === 'startTorrentingSummary') {
startTorrentingSummary(args[0] /* torrentSummary */)
}
// Playback // Playback
if (action === 'openItem') { if (action === 'playFile') {
openItem(args[0] /* infoHash */, args[1] /* index */) controllers.playback.playFile(args[0] /* infoHash */, args[1] /* index */)
} }
if (action === 'playPause') { if (action === 'playPause') {
playPause() controllers.playback.playPause()
} }
if (action === 'play') { if (action === 'skipTo') {
playFile(args[0] /* infoHash */, args[1] /* index */) controllers.playback.skipTo(args[0] /* seconds */)
}
if (action === 'playbackJump') {
jumpToTime(args[0] /* seconds */)
}
if (action === 'skip') {
jumpToTime(state.playing.currentTime + (args[0] /* direction */ * 10))
} }
if (action === 'changePlaybackRate') { if (action === 'changePlaybackRate') {
changePlaybackRate(args[0] /* direction */) controllers.playback.changePlaybackRate(args[0] /* direction */)
} }
if (action === 'changeVolume') { if (action === 'changeVolume') {
changeVolume(args[0] /* increase */) controllers.playback.changeVolume(args[0] /* increase */)
} }
if (action === 'setVolume') { if (action === 'setVolume') {
setVolume(args[0] /* increase */) controllers.playback.setVolume(args[0] /* increase */)
} }
if (action === 'openItem') {
controllers.playback.openItem(args[0] /* infoHash */, args[1] /* index */)
}
// Subtitles
if (action === 'openSubtitles') { if (action === 'openSubtitles') {
openSubtitles() controllers.subtitles.openSubtitles()
} }
if (action === 'selectSubtitle') { if (action === 'selectSubtitle') {
selectSubtitle(args[0] /* index */) controllers.subtitles.selectSubtitle(args[0] /* index */)
} }
if (action === 'toggleSubtitlesMenu') { if (action === 'toggleSubtitlesMenu') {
toggleSubtitlesMenu() controllers.subtitles.toggleSubtitlesMenu()
} }
if (action === 'checkForSubtitles') {
controllers.subtitles.checkForSubtitles()
}
// Local media: <video>, <audio>, VLC
if (action === 'mediaStalled') { if (action === 'mediaStalled') {
controllers.media.mediaStalled() controllers.media.mediaStalled()
} }
@@ -263,7 +271,7 @@ function dispatch (action, ...args) {
controllers.media.vlcNotFound() controllers.media.vlcNotFound()
} }
// Casting: Chromecast, Airplay, etc // Remote casting: Chromecast, Airplay, etc
if (action === 'toggleCastMenu') { if (action === 'toggleCastMenu') {
lazyLoadCast().toggleMenu(args[0] /* deviceType */) lazyLoadCast().toggleMenu(args[0] /* deviceType */)
} }
@@ -333,7 +341,8 @@ function dispatch (action, ...args) {
} }
// Update the virtual-dom, unless it's just a mouse move event // Update the virtual-dom, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' || showOrHidePlayerControls()) { if (action !== 'mediaMouseMoved' ||
controllers.playback.showOrHidePlayerControls()) {
update() update()
} }
} }
@@ -365,91 +374,6 @@ function setupIpc () {
ipcRenderer.send('ipcReady') ipcRenderer.send('ipcReady')
} }
function play () {
if (!state.playing.isPaused) return
state.playing.isPaused = false
if (isCasting()) {
Cast.play()
}
ipcRenderer.send('onPlayerPlay')
}
function pause () {
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting()) {
Cast.pause()
}
ipcRenderer.send('onPlayerPause')
}
function playPause () {
if (state.location.url() !== 'player') return
if (state.playing.isPaused) {
play()
} else {
pause()
}
// force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately
if (!state.window.isVisible) render(state)
}
function jumpToTime (time) {
if (isCasting()) {
Cast.seek(time)
} else {
state.playing.jumpToTime = time
}
}
function changePlaybackRate (direction) {
var rate = state.playing.playbackRate
if (direction > 0 && rate >= 0.25 && rate < 2) {
rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25
} else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */
rate = -1
} else if (direction > 0 && rate === -1) {
rate = 0.25
} else if ((direction > 0 && rate >= 1 && rate < 16) || (direction < 0 && rate > -16 && rate <= -1)) {
rate *= 2
} else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) {
rate /= 2
}
state.playing.playbackRate = rate
if (lazyLoadCast().isCasting() && !Cast.setRate(rate)) {
state.playing.playbackRate = 1
}
}
function changeVolume (delta) {
// change volume with delta value
setVolume(state.playing.volume + delta)
}
function setVolume (volume) {
// check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume))
if (isCasting()) {
Cast.setVolume(volume)
} else {
state.playing.setVolume = volume
}
}
function openSubtitles () {
electron.remote.dialog.showOpenDialog({
title: 'Select a subtitles file.',
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
properties: [ 'openFile' ]
}, function (filenames) {
if (!Array.isArray(filenames)) return
addSubtitles(filenames, true)
})
}
// Quits any modal popovers and returns to the torrent list screen // Quits any modal popovers and returns to the torrent list screen
function backToList () { function backToList () {
// Exit any modals and screens with a back button // Exit any modals and screens with a back button
@@ -479,15 +403,6 @@ function escapeBack () {
} }
} }
// 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' ||
state.playing.location === 'dlna'
}
// Starts all torrents that aren't paused on program startup // Starts all torrents that aren't paused on program startup
function resumeTorrents () { function resumeTorrents () {
state.saved.torrents state.saved.torrents
@@ -502,121 +417,12 @@ function isTorrent (file) {
return isTorrentFile || isMagnet return isTorrentFile || isMagnet
} }
function isSubtitle (file) {
var name = typeof file === 'string' ? file : file.name
var ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt'
}
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents // Gets a torrent summary {name, infoHash, status} from state.saved.torrents
// Returns undefined if we don't know that infoHash // Returns undefined if we don't know that infoHash
function getTorrentSummary (torrentKey) { function getTorrentSummary (torrentKey) {
return TorrentSummary.getByKey(state, torrentKey) return TorrentSummary.getByKey(state, torrentKey)
} }
function addSubtitles (files, autoSelect) {
// Subtitles are only supported when playing video files
if (state.playing.type !== 'video') return
if (files.length === 0) return
// Read the files concurrently, then add all resulting subtitle tracks
var tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
parallel(tasks, function (err, tracks) {
if (err) return onError(err)
for (var i = 0; i < tracks.length; i++) {
// No dupes allowed
var track = tracks[i]
if (state.playing.subtitles.tracks.some(
(t) => track.filePath === t.filePath)) continue
// Add the track
state.playing.subtitles.tracks.push(track)
// If we're auto-selecting a track, try to find one in the user's language
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
state.playing.subtitles.selectedIndex =
state.playing.subtitles.tracks.length - 1
}
}
// Finally, make sure no two tracks have the same label
relabelSubtitles()
})
}
function loadSubtitle (file, cb) {
var concat = require('simple-concat')
var LanguageDetect = require('languagedetect')
var srtToVtt = require('srt-to-vtt')
// Read the .SRT or .VTT file, parse it, add subtitle track
var filePath = file.path || file
var vttStream = fs.createReadStream(filePath).pipe(srtToVtt())
concat(vttStream, function (err, buf) {
if (err) return onError(new Error('Error parsing subtitles file.'))
// Detect what language the subtitles are in
var vttContents = buf.toString().replace(/(.*-->.*)/g, '')
var langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected,
label: langDetected,
filePath: filePath
}
cb(null, track)
})
}
function selectSubtitle (ix) {
state.playing.subtitles.selectedIndex = ix
}
// Checks whether a language name like "English" or "German" matches the system
// language, aka the current locale
function isSystemLanguage (language) {
var iso639 = require('iso-639-1')
var osLangISO = window.navigator.language.split('-')[0] // eg "en"
var langIso = iso639.getCode(language) // eg "de" if language is "German"
return langIso === osLangISO
}
// Make sure we don't have two subtitle tracks with the same label
// Labels each track by language, eg "German", "English", "English 2", ...
function relabelSubtitles () {
var counts = {}
state.playing.subtitles.tracks.forEach(function (track) {
var lang = track.language
counts[lang] = (counts[lang] || 0) + 1
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang
})
}
function checkForSubtitles () {
if (state.playing.type !== 'video') return
var torrentSummary = state.getPlayingTorrentSummary()
if (!torrentSummary || !torrentSummary.progress) return
torrentSummary.progress.files.forEach(function (fp, ix) {
if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files
var file = torrentSummary.files[ix]
if (!isSubtitle(file.name)) return
var filePath = path.join(torrentSummary.path, file.path)
addSubtitles([filePath], false)
})
}
function toggleSubtitlesMenu () {
state.playing.subtitles.showMenu = !state.playing.subtitles.showMenu
}
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object // Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
function startTorrentingSummary (torrentSummary) { function startTorrentingSummary (torrentSummary) {
var s = torrentSummary var s = torrentSummary
@@ -690,7 +496,7 @@ function torrentMetadata (torrentKey, torrentInfo) {
if (!torrentSummary.selections) { if (!torrentSummary.selections) {
torrentSummary.selections = torrentSummary.files.map((x) => true) torrentSummary.selections = torrentSummary.files.map((x) => true)
} }
torrentSummary.defaultPlayFileIndex = pickFileToPlay(torrentInfo.files) torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files)
update() update()
// Save the .torrent file, if it hasn't been saved already // Save the .torrent file, if it hasn't been saved already
@@ -780,172 +586,6 @@ function torrentServerRunning (serverInfo) {
state.server = serverInfo state.server = serverInfo
} }
// 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 playFile (infoHash, index) {
state.location.go({
url: 'player',
onbeforeload: function (cb) {
play()
openPlayer(infoHash, index, cb)
},
onbeforeunload: closePlayer
}, function (err) {
if (err) onError(err)
})
}
// Opens the video player to a specific torrent
function openPlayer (infoHash, index, cb) {
var torrentSummary = getTorrentSummary(infoHash)
// automatically choose which file in the torrent to play, if necessary
if (index === undefined) index = torrentSummary.defaultPlayFileIndex
if (index === undefined) index = pickFileToPlay(torrentSummary.files)
if (index === undefined) return cb(new errors.UnplayableError())
// update UI to show pending playback
if (torrentSummary.progress !== 1) sound.play('PLAY')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'requested'
update()
var timeout = setTimeout(function () {
telemetry.logPlayAttempt('timeout')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR')
cb(new Error('Playback timed out. Try again.'))
update()
}, 10000) /* give it a few seconds */
if (torrentSummary.status === 'paused') {
startTorrentingSummary(torrentSummary)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb))
} else {
openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)
}
}
function openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
var fileSummary = torrentSummary.files[index]
// update 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 fraction = fileSummary.currentTime / fileSummary.duration
var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime
}
}
// 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)
}
// if it's video, check for subtitles files that are done downloading
checkForSubtitles()
ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index)
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, function (e, info) {
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 update()
}
// otherwise, play the video
state.window.title = torrentSummary.files[state.playing.fileIndex].name
update()
ipcRenderer.send('onPlayerOpen')
cb()
})
}
function closePlayer (cb) {
console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC
if (isCasting()) {
Cast.stop()
}
if (state.playing.location === 'vlc') {
ipcRenderer.send('vlcQuit')
}
// Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume
// Telemetry: track what happens after the user clicks play
var result = state.playing.result // 'success' or 'error'
if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed
else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc
else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen
state.window.title = config.APP_WINDOW_TITLE
state.playing = State.getDefaultPlayState()
state.server = null
// Reset the window size and location back to where it was
if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false)
}
restoreBounds()
// Tell the WebTorrent process to kill the torrent-to-HTTP server
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('onPlayerClose')
update()
cb()
}
function openItem (infoHash, index) {
var torrentSummary = getTorrentSummary(infoHash)
var filePath = path.join(
torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
}
function getTorrentPath (torrentSummary) { function getTorrentPath (torrentSummary) {
var itemPath = path.join(torrentSummary.path, torrentSummary.files[0].path) var itemPath = path.join(torrentSummary.path, torrentSummary.files[0].path)
if (torrentSummary.files.length > 1) { if (torrentSummary.files.length > 1) {
@@ -993,13 +633,6 @@ function setDimensions (dimensions) {
state.playing.aspectRatio = aspectRatio state.playing.aspectRatio = aspectRatio
} }
function restoreBounds () {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}
function showDoneNotification (torrent) { function showDoneNotification (torrent) {
var notif = new window.Notification('Download Complete', { var notif = new window.Notification('Download Complete', {
body: torrent.name, body: torrent.name,
@@ -1013,25 +646,6 @@ function showDoneNotification (torrent) {
sound.play('DONE') sound.play('DONE')
} }
// Hide player controls while playing video, if the mouse stays still for a while
// 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
function showOrHidePlayerControls () {
var hideControls = state.location.url() === 'player' &&
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local'
if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls
return true
}
return false
}
// Called when the user adds files (.torrent, files to seed, subtitles) to the app // Called when the user adds files (.torrent, files to seed, subtitles) to the app
// via any method (drag-drop, drag to app icon, command line) // via any method (drag-drop, drag to app icon, command line)
function onOpen (files) { function onOpen (files) {
@@ -1041,7 +655,7 @@ function onOpen (files) {
state.modal = null state.modal = null
} }
var subtitles = files.filter(isSubtitle) var subtitles = files.filter(controllers.subtitles.isSubtitle)
if (state.location.url() === 'home' || subtitles.length === 0) { if (state.location.url() === 'home' || subtitles.length === 0) {
if (files.every(isTorrent)) { if (files.every(isTorrent)) {
@@ -1055,7 +669,7 @@ function onOpen (files) {
controllers.torrentList.showCreateTorrent(files) controllers.torrentList.showCreateTorrent(files)
} }
} else if (state.location.url() === 'player') { } else if (state.location.url() === 'player') {
addSubtitles(subtitles, true) controllers.subtitles.addSubtitles(subtitles, true)
} }
update() update()

View File

@@ -4,7 +4,7 @@ var hx = require('../lib/hx')
var Header = require('./header') var Header = require('./header')
var Views = { var Views = {
'home': require('./home'), 'home': require('./torrent-list'),
'player': require('./player'), 'player': require('./player'),
'create-torrent': require('./create-torrent'), 'create-torrent': require('./create-torrent'),
'preferences': require('./preferences') 'preferences': require('./preferences')

View File

@@ -519,7 +519,7 @@ function renderPlayerControls (state) {
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.playing.duration /* seconds */ var position = fraction * state.playing.duration /* seconds */
dispatch('playbackJump', position) dispatch('skipTo', position)
} }
// Handles volume muting and Unmuting // Handles volume muting and Unmuting

View File

@@ -153,7 +153,7 @@ function TorrentList (state) {
<i.button-round.icon.play <i.button-round.icon.play
title=${playTooltip} title=${playTooltip}
class=${playClass} class=${playClass}
onclick=${dispatcher('play', infoHash)}> onclick=${dispatcher('playFile', infoHash)}>
${playIcon} ${playIcon}
</i> </i>
` `
@@ -242,7 +242,7 @@ function TorrentList (state) {
var handleClick var handleClick
if (isPlayable) { if (isPlayable) {
icon = 'play_arrow' /* playable? add option to play */ icon = 'play_arrow' /* playable? add option to play */
handleClick = dispatcher('play', infoHash, index) handleClick = dispatcher('playFile', infoHash, index)
} else { } else {
icon = 'description' /* file icon, opens in OS default app */ icon = 'description' /* file icon, opens in OS default app */
handleClick = dispatcher('openItem', infoHash, index) handleClick = dispatcher('openItem', infoHash, index)