Refactor main.js: playback and subtitles controllers
This commit is contained in:
299
renderer/controllers/playback-controller.js
Normal file
299
renderer/controllers/playback-controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
134
renderer/controllers/subtitles-controller.js
Normal file
134
renderer/controllers/subtitles-controller.js
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user