Files
webtorrent-desktop/renderer/index.js
Feross Aboukhadijeh 72f917a744 OS X: Make controls use full window in fullscreen
This bug was subtle. Basically, on OS X only, we use
window.setAspectRatio() to make the player window match the video size.

But this is maintained even in fullscreen mode, which makes the window
actually not use up the fullscreen, and there are black bars above and
below the video player controls, which looks really weird.

Unset the aspect ratio in fullscreen mode, then set it again upon
leaving fullscreen mode.
2016-05-12 17:09:10 -07:00

1164 lines
35 KiB
JavaScript

console.time('init')
var crashReporter = require('../crash-reporter')
crashReporter.init()
var electron = require('electron')
// Electron apps have two processes: a main process (node) runs first and starts
// a renderer process (essentially a Chrome window). We're in the renderer process,
// and this IPC channel receives from and sends messages to the main process
var ipcRenderer = electron.ipcRenderer
// Listen for messages from the main process
setupIpc()
var appConfig = require('application-config')('WebTorrent')
var concat = require('concat-stream')
var dragDrop = require('drag-drop')
var fs = require('fs-extra')
var mainLoop = require('main-loop')
var path = require('path')
var createElement = require('virtual-dom/create-element')
var diff = require('virtual-dom/diff')
var patch = require('virtual-dom/patch')
var App = require('./views/app')
var config = require('../config')
var errors = require('./lib/errors')
var sound = require('./lib/sound')
var State = require('./state')
var TorrentPlayer = require('./lib/torrent-player')
var TorrentSummary = require('./lib/torrent-summary')
var {setDispatch} = require('./lib/dispatcher')
setDispatch(dispatch)
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
// This dependency is the slowest-loading, so we lazy load it
var Cast = null
// For easy debugging in Developer Tools
var state = global.state = State.getInitialState()
var vdomLoop
// All state lives in state.js. `state.saved` is read from and written to a file.
// All other state is ephemeral. First we load state.saved then initialize the app.
loadState(init)
/**
* Called once when the application loads. (Not once per window.)
* Connects to the torrent networks, sets up the UI and OS integrations like
* the dock icon and drag+drop.
*/
function init () {
// Clean up the freshly-loaded config file, which may be from an older version
cleanUpConfig()
// Push the first page into the location history
state.location.go({ url: 'home' })
// Restart everything we were torrenting last time the app ran
resumeTorrents()
// Lazy-load other stuff, like the AppleTV module, later to keep startup fast
window.setTimeout(delayedInit, config.DELAYED_INIT)
// The UI is built with virtual-dom, a minimalist library extracted from React
// The concepts--one way data flow, a pure function that renders state to a
// virtual DOM tree, and a diff that applies changes in the vdom to the real
// DOM, are all the same. Learn more: https://facebook.github.io/react/
vdomLoop = mainLoop(state, render, {
create: createElement,
diff: diff,
patch: patch
})
document.body.appendChild(vdomLoop.target)
// Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000)
// OS integrations:
// ...drag and drop a torrent or video file to play or seed
dragDrop('body', (files) => dispatch('onOpen', files))
// ...same thing if you paste a torrent
document.addEventListener('paste', onPaste)
// ...focus and blur. Needed to show correct dock icon text ("badge") in OSX
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
// Done! Ideally we want to get here <100ms after the user clicks the app
sound.play('STARTUP')
console.timeEnd('init')
}
function delayedInit () {
lazyLoadCast()
sound.preload()
}
// Change `state.saved` (which will be saved back to config.json on exit) as
// needed, for example to deal with config.json format changes across versions
function cleanUpConfig () {
state.saved.torrents.forEach(function (ts) {
var infoHash = ts.infoHash
// Migration: replace torrentPath with torrentFileName
var src, dst
if (ts.torrentPath) {
console.log('migration: replacing torrentPath %s', ts.torrentPath)
src = path.isAbsolute(ts.torrentPath)
? ts.torrentPath
: path.join(config.STATIC_PATH, ts.torrentPath)
dst = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent'
}
// Migration: replace posterURL with posterFileName
if (ts.posterURL) {
console.log('migration: replacing posterURL %s', ts.posterURL)
var extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL)
? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.CONFIG_POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.posterURL
ts.posterFileName = infoHash + extension
}
})
}
// Lazily loads Chromecast and Airplay support
function lazyLoadCast () {
if (!Cast) {
Cast = require('./lib/cast')
Cast.init(state, update) // Search the local network for Chromecast and Airplays
}
return Cast
}
// This is the (mostly) pure function from state -> UI. Returns a virtual DOM
// tree. Any events, such as button clicks, will turn into calls to dispatch()
function render (state) {
try {
return App(state)
} catch (e) {
console.log('rendering error: %s\n\t%s', e.message, e.stack)
}
}
// Calls render() to go from state -> UI, then applies to vdom to the real DOM.
function update () {
showOrHidePlayerControls()
vdomLoop.update(state)
updateElectron()
}
function updateElectron () {
if (state.window.title !== state.prev.title) {
state.prev.title = state.window.title
ipcRenderer.send('setTitle', state.window.title)
}
if (state.dock.progress !== state.prev.progress) {
state.prev.progress = state.dock.progress
ipcRenderer.send('setProgress', state.dock.progress)
}
if (state.dock.badge !== state.prev.badge) {
state.prev.badge = state.dock.badge
ipcRenderer.send('setBadge', state.dock.badge || '')
}
}
// Events from the UI never modify state directly. Instead they call dispatch()
function dispatch (action, ...args) {
// Log dispatch calls, for debugging
if (!['mediaMouseMoved', 'mediaTimeUpdate'].includes(action)) {
console.log('dispatch: %s %o', action, args)
}
if (action === 'onOpen') {
onOpen(args[0] /* files */)
}
if (action === 'addTorrent') {
addTorrent(args[0] /* torrent */)
}
if (action === 'showOpenTorrentFile') {
ipcRenderer.send('showOpenTorrentFile') /* open torrent file */
}
if (action === 'showCreateTorrent') {
showCreateTorrent(args[0] /* fileOrFolder */)
}
if (action === 'createTorrent') {
createTorrent(args[0] /* options */)
}
if (action === 'openFile') {
openFile(args[0] /* infoHash */, args[1] /* index */)
}
if (action === 'toggleTorrent') {
toggleTorrent(args[0] /* infoHash */)
}
if (action === 'deleteTorrent') {
deleteTorrent(args[0] /* infoHash */)
}
if (action === 'toggleSelectTorrent') {
toggleSelectTorrent(args[0] /* infoHash */)
}
if (action === 'openTorrentContextMenu') {
openTorrentContextMenu(args[0] /* infoHash */)
}
if (action === 'openDevice') {
lazyLoadCast().open(args[0] /* deviceType */)
}
if (action === 'closeDevice') {
lazyLoadCast().close()
}
if (action === 'setDimensions') {
setDimensions(args[0] /* dimensions */)
}
if (action === 'backToList') {
// Exit any modals and screens with a back button
state.modal = null
while (state.location.hasBack()) state.location.back()
// Work around virtual-dom issue: it doesn't expose its redraw function,
// and only redraws on requestAnimationFrame(). That means when the user
// closes the window (hide window / minimize to tray) and we want to pause
// the video, we update the vdom but it keeps playing until you reopen!
var mediaTag = document.querySelector('video,audio')
if (mediaTag) mediaTag.pause()
}
if (action === 'escapeBack') {
if (state.modal) {
dispatch('exitModal')
} else if (state.window.isFullScreen) {
dispatch('toggleFullScreen')
} else {
dispatch('back')
}
}
if (action === 'back') {
state.location.back()
}
if (action === 'forward') {
state.location.forward()
}
if (action === 'playPause') {
playPause()
}
if (action === 'play') {
if (state.location.pending()) return
state.location.go({
url: 'player',
onbeforeload: function (cb) {
openPlayer(args[0] /* infoHash */, args[1] /* index */, cb)
},
onbeforeunload: closePlayer
})
play()
}
if (action === 'playbackJump') {
jumpToTime(args[0] /* seconds */)
}
if (action === 'changeVolume') {
changeVolume(args[0] /* increase */)
}
if (action === 'setVolume') {
setVolume(args[0] /* increase */)
}
if (action === 'openSubtitles') {
openSubtitles()
}
if (action === 'selectSubtitle') {
selectSubtitle(args[0] /* label */)
}
if (action === 'showSubtitles') {
showSubtitles()
}
if (action === 'mediaStalled') {
state.playing.isStalled = true
}
if (action === 'mediaError') {
if (state.location.current().url === 'player') {
state.playing.location = 'error'
ipcRenderer.send('checkForVLC')
ipcRenderer.once('checkForVLC', function (e, isInstalled) {
state.modal = {
id: 'unsupported-media-modal',
error: args[0],
vlcInstalled: isInstalled
}
})
}
}
if (action === 'mediaTimeUpdate') {
state.playing.lastTimeUpdate = new Date().getTime()
state.playing.isStalled = false
}
if (action === 'mediaMouseMoved') {
state.playing.mouseStationarySince = new Date().getTime()
}
if (action === 'vlcPlay') {
ipcRenderer.send('vlcPlay', state.server.localURL)
state.playing.location = 'vlc'
}
if (action === 'vlcNotFound') {
if (state.modal && state.modal.id === 'unsupported-media-modal') {
state.modal.vlcNotFound = true
}
}
if (action === 'toggleFullScreen') {
ipcRenderer.send('toggleFullScreen', args[0] /* optional bool */)
}
if (action === 'exitModal') {
state.modal = null
}
if (action === 'updateAvailable') {
updateAvailable(args[0] /* version */)
}
if (action === 'skipVersion') {
if (!state.saved.skippedVersions) state.saved.skippedVersions = []
state.saved.skippedVersions.push(args[0] /* version */)
saveStateThrottled()
}
if (action === 'saveState') {
saveState()
}
// Update the virtual-dom, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' || showOrHidePlayerControls()) {
update()
}
}
// Shows a modal saying that we have an update
function updateAvailable (version) {
if (state.saved.skippedVersions && state.saved.skippedVersions.includes(version)) {
console.log('new version skipped by user: v' + version)
return
}
state.modal = { id: 'update-available-modal', version: version }
}
function play () {
if (!state.playing.isPaused) return
state.playing.isPaused = false
if (isCasting()) {
Cast.play()
}
ipcRenderer.send('blockPowerSave')
}
function pause () {
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting()) {
Cast.pause()
}
ipcRenderer.send('unblockPowerSave')
}
function playPause () {
if (state.location.current().url !== 'player') return
if (state.playing.isPaused) {
play()
} else {
pause()
}
}
function jumpToTime (time) {
if (isCasting()) {
Cast.seek(time)
} else {
state.playing.jumpToTime = time
}
}
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
addSubtitle({path: filenames[0]})
})
}
// 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'
}
function setupIpc () {
ipcRenderer.send('ipcReady')
ipcRenderer.on('log', (e, ...args) => console.log(...args))
ipcRenderer.on('error', (e, ...args) => console.error(...args))
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
ipcRenderer.on('showOpenTorrentAddress', function (e) {
state.modal = { id: 'open-torrent-address-modal' }
update()
})
ipcRenderer.on('fullscreenChanged', function (e, isFullScreen) {
state.window.isFullScreen = isFullScreen
if (!isFullScreen) {
// Aspect ratio gets reset in fullscreen mode, so restore it (OS X)
ipcRenderer.send('setAspectRatio', state.playing.aspectRatio)
}
update()
})
ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => torrentDone(...args))
ipcRenderer.on('wt-warning', (e, ...args) => torrentWarning(...args))
ipcRenderer.on('wt-error', (e, ...args) => torrentError(...args))
ipcRenderer.on('wt-progress', (e, ...args) => torrentProgress(...args))
ipcRenderer.on('wt-file-modtimes', (e, ...args) => torrentFileModtimes(...args))
ipcRenderer.on('wt-file-saved', (e, ...args) => torrentFileSaved(...args))
ipcRenderer.on('wt-poster', (e, ...args) => torrentPosterSaved(...args))
ipcRenderer.on('wt-audio-metadata', (e, ...args) => torrentAudioMetadata(...args))
ipcRenderer.on('wt-server-running', (e, ...args) => torrentServerRunning(...args))
}
// Load state.saved from the JSON state file
function loadState (cb) {
appConfig.read(function (err, data) {
if (err) console.error(err)
console.log('loaded state from ' + appConfig.filePath)
// populate defaults if they're not there
state.saved = Object.assign({}, State.getDefaultSavedState(), data)
state.saved.torrents.forEach(function (torrentSummary) {
if (torrentSummary.displayName) torrentSummary.name = torrentSummary.displayName
})
if (cb) cb()
})
}
// Starts all torrents that aren't paused on program startup
function resumeTorrents () {
state.saved.torrents
.filter((x) => x.status !== 'paused')
.forEach((x) => startTorrentingSummary(x))
}
// Don't write state.saved to file more than once a second
function saveStateThrottled () {
if (state.saveStateTimeout) return
state.saveStateTimeout = setTimeout(function () {
delete state.saveStateTimeout
saveState()
}, 1000)
}
// Write state.saved to the JSON state file
function saveState () {
console.log('saving state to ' + appConfig.filePath)
// Clean up, so that we're not saving any pending state
var copy = Object.assign({}, state.saved)
// Remove torrents pending addition to the list, where we haven't finished
// reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents
.filter((x) => x.infoHash)
.map(function (x) {
var torrent = {}
for (var key in x) {
if (key === 'progress' || key === 'torrentKey') {
continue // Don't save progress info or key for the webtorrent process
}
if (key === 'playStatus' && x.playStatus !== 'unplayable') {
continue // Don't save whether a torrent is playing / pending
}
torrent[key] = x[key]
}
return torrent
})
appConfig.write(copy, function (err) {
if (err) console.error(err)
ipcRenderer.send('savedState')
})
// Update right away, don't wait for the state to save
update()
}
function onOpen (files) {
if (!Array.isArray(files)) files = [ files ]
// .torrent file = start downloading the torrent
files.filter(isTorrent).forEach(addTorrent)
// subtitle file
files.filter(isSubtitle).forEach(addSubtitle)
// everything else = seed these files
var rest = files.filter(not(isTorrent)).filter(not(isSubtitle))
if (rest.length > 0) showCreateTorrent(rest)
}
function isTorrent (file) {
var name = typeof file === 'string' ? file : file.name
var isTorrentFile = path.extname(name).toLowerCase() === '.torrent'
var isMagnet = typeof file === 'string' && /^magnet:/.test(file)
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'
}
function not (test) {
return function (...args) {
return !test(...args)
}
}
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
// Returns undefined if we don't know that infoHash
function getTorrentSummary (torrentKey) {
if (!torrentKey) return undefined
return state.saved.torrents.find((x) =>
x.torrentKey === torrentKey || x.infoHash === torrentKey)
}
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
function addTorrent (torrentId) {
var torrentKey = state.nextTorrentKey++
var path = state.saved.downloadPath
if (torrentId.path) {
// Use path string instead of W3C File object
torrentId = torrentId.path
}
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
}
function addSubtitle (file) {
var srtToVtt = require('srt-to-vtt')
var LanguageDetect = require('languagedetect')
if (state.playing.type !== 'video') return
fs.createReadStream(file.path || file).pipe(srtToVtt()).pipe(concat(function (buf) {
// Set the cue text position so it appears above the player controls.
// The only way to change cue text position is by modifying the VTT. It is not
// possible via CSS.
var langDetected = (new LanguageDetect()).detect(buf.toString().replace(/(.*-->.*)/g, ''), 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var subtitles = Buffer(buf.toString().replace(/(-->.*)/g, '$1 line:88%'))
var track = {
buffer: 'data:text/vtt;base64,' + subtitles.toString('base64'),
label: langDetected,
selected: true
}
state.playing.subtitles.tracks.forEach(function (trackItem) {
trackItem.selected = false
if (trackItem.label === track.label) {
track.label = Number.isNaN(track.label.slice(-1))
? track.label + ' 2'
: track.label.slice(0, -1) + (parseInt(track.label.slice(-1)) + 1)
}
})
state.playing.subtitles.change = track.label
state.playing.subtitles.tracks.push(track)
state.playing.subtitles.enabled = true
}))
}
function selectSubtitle (label) {
state.playing.subtitles.tracks.forEach(function (track) {
track.selected = (track.label === label)
})
state.playing.subtitles.enabled = !!label
state.playing.subtitles.change = label
state.playing.subtitles.show = false
}
function showSubtitles () {
state.playing.subtitles.show = !state.playing.subtitles.show
}
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
function startTorrentingSummary (torrentSummary) {
var s = torrentSummary
// Backward compatibility for config files save before we had torrentKey
if (!s.torrentKey) s.torrentKey = state.nextTorrentKey++
// Use Downloads folder by default
var path = s.path || state.saved.downloadPath
var torrentID
if (s.torrentFileName) { // Load torrent file from disk
torrentID = TorrentSummary.getTorrentPath(torrentSummary)
} else { // Load torrent from DHT
torrentID = s.magnetURI || s.infoHash
}
console.log('start torrenting %s %s', s.torrentKey, torrentID)
ipcRenderer.send('wt-start-torrenting', s.torrentKey, torrentID, path, s.fileModtimes)
}
//
// TORRENT MANAGEMENT
// Send commands to the WebTorrent process, handle events
//
// Shows the Create Torrent page with options to seed a given file or folder
function showCreateTorrent (files) {
if (Array.isArray(files)) {
if (state.location.pending() || state.location.current().url !== 'home') return
state.location.go({
url: 'create-torrent',
files: files
})
return
}
var fileOrFolder = files
findFilesRecursive(fileOrFolder, showCreateTorrent)
}
// Recursively finds {name, path, size} for all files in a folder
// Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (fileOrFolder, cb) {
fs.stat(fileOrFolder, function (err, stat) {
if (err) return onError(err)
// Files: return name, path, and size
if (!stat.isDirectory()) {
var filePath = fileOrFolder
return cb([{
name: path.basename(filePath),
path: filePath,
size: stat.size
}])
}
// Folders: recurse, make a list of all the files
var folderPath = fileOrFolder
fs.readdir(folderPath, function (err, fileNames) {
if (err) return onError(err)
var numComplete = 0
var ret = []
fileNames.forEach(function (fileName) {
findFilesRecursive(path.join(folderPath, fileName), function (fileObjs) {
ret = ret.concat(fileObjs)
if (++numComplete === fileNames.length) {
cb(ret)
}
})
})
})
})
}
// Creates a new torrent and start seeeding
function createTorrent (options) {
var torrentKey = state.nextTorrentKey++
ipcRenderer.send('wt-create-torrent', torrentKey, options)
}
function torrentInfoHash (torrentKey, infoHash) {
var torrentSummary = getTorrentSummary(torrentKey)
console.log('got infohash for %s torrent %s',
torrentSummary ? 'existing' : 'new', torrentKey)
if (!torrentSummary) {
torrentSummary = {
torrentKey: torrentKey,
status: 'new'
}
state.saved.torrents.push(torrentSummary)
sound.play('ADD')
}
torrentSummary.infoHash = infoHash
update()
}
function torrentWarning (torrentKey, message) {
onWarning(message)
}
function torrentError (torrentKey, message) {
// TODO: WebTorrent needs semantic errors
if (message.startsWith('Cannot add duplicate torrent')) {
// Remove infohash from the message
message = 'Cannot add duplicate torrent'
}
onError(message)
var torrentSummary = getTorrentSummary(torrentKey)
if (torrentSummary) {
console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message)
torrentSummary.status = 'paused'
update()
}
}
function torrentMetadata (torrentKey, torrentInfo) {
// Summarize torrent
var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.status = 'downloading'
torrentSummary.name = torrentSummary.displayName || torrentInfo.name
torrentSummary.path = torrentInfo.path
torrentSummary.files = torrentInfo.files
torrentSummary.magnetURI = torrentInfo.magnetURI
update()
// Save the .torrent file, if it hasn't been saved already
if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey)
// Auto-generate a poster image, if it hasn't been generated already
if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey)
}
function torrentDone (torrentKey, torrentInfo) {
// Update the torrent summary
var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.status = 'seeding'
// Notify the user that a torrent finished, but only if we actually DL'd at least part of it.
// Don't notify if we merely finished verifying data files that were already on disk.
if (torrentInfo.bytesReceived > 0) {
if (!state.window.isFocused) {
state.dock.badge += 1
}
showDoneNotification(torrentSummary)
}
update()
}
function torrentProgress (progressInfo) {
// Overall progress across all active torrents, 0 to 1
var progress = progressInfo.progress
var hasActiveTorrents = progressInfo.hasActiveTorrents
// Hide progress bar when client has no torrents, or progress is 100%
// TODO: isn't this equivalent to: if (progress === 1) ?
if (!hasActiveTorrents || progress === 1) {
progress = -1
}
// Show progress bar under the WebTorrent taskbar icon, on OSX
state.dock.progress = progress
// Update progress for each individual torrent
progressInfo.torrents.forEach(function (p) {
var torrentSummary = getTorrentSummary(p.torrentKey)
if (!torrentSummary) {
console.log('warning: got progress for missing torrent %s', p.torrentKey)
return
}
torrentSummary.progress = p
})
update()
}
function torrentFileModtimes (torrentKey, fileModtimes) {
var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.fileModtimes = fileModtimes
saveStateThrottled()
}
function torrentFileSaved (torrentKey, torrentFileName) {
console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.torrentFileName = torrentFileName
saveStateThrottled()
}
function torrentPosterSaved (torrentKey, posterFileName) {
var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.posterFileName = posterFileName
saveStateThrottled()
}
function torrentAudioMetadata (infoHash, index, info) {
var torrentSummary = getTorrentSummary(infoHash)
var fileSummary = torrentSummary.files[index]
fileSummary.audioInfo = info
update()
}
function torrentServerRunning (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
}
// Opens the video player
function openPlayer (infoHash, index, cb) {
var torrentSummary = getTorrentSummary(infoHash)
// automatically choose which file in the torrent to play, if necessary
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')
torrentSummary.playStatus = 'requested'
update()
var timeout = setTimeout(function () {
torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR')
cb(new Error('playback timed out'))
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'
// 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-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) {
if (isCasting()) {
Cast.close()
}
if (state.playing.location === 'vlc') {
ipcRenderer.send('vlcQuit')
}
state.window.title = config.APP_WINDOW_TITLE
state.playing = State.getDefaultPlayState()
state.server = null
if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false)
}
restoreBounds()
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('unblockPowerSave')
ipcRenderer.send('onPlayerClose')
update()
cb()
}
function openFile (infoHash, index) {
var torrentSummary = getTorrentSummary(infoHash)
var filePath = path.join(
torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
}
// TODO: use torrentKey, not infoHash
function toggleTorrent (infoHash) {
var torrentSummary = getTorrentSummary(infoHash)
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new'
startTorrentingSummary(torrentSummary)
sound.play('ENABLE')
} else {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
}
}
// TODO: use torrentKey, not infoHash
function deleteTorrent (infoHash) {
ipcRenderer.send('wt-stop-torrenting', infoHash)
var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) state.saved.torrents.splice(index, 1)
saveStateThrottled()
state.location.clearForward() // prevent user from going forward to a deleted torrent
sound.play('DELETE')
}
function toggleSelectTorrent (infoHash) {
// toggle selection
state.selectedInfoHash = state.selectedInfoHash === infoHash ? null : infoHash
update()
}
function openTorrentContextMenu (infoHash) {
var torrentSummary = getTorrentSummary(infoHash)
var menu = new electron.remote.Menu()
if (torrentSummary.files) {
menu.append(new electron.remote.MenuItem({
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder',
click: () => showItemInFolder(torrentSummary)
}))
menu.append(new electron.remote.MenuItem({
type: 'separator'
}))
}
menu.append(new electron.remote.MenuItem({
label: 'Copy Magnet Link to Clipboard',
click: () => electron.clipboard.writeText(torrentSummary.magnetURI)
}))
menu.append(new electron.remote.MenuItem({
label: 'Copy Instant.io Link to Clipboard',
click: () => electron.clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`)
}))
menu.append(new electron.remote.MenuItem({
label: 'Save Torrent File As...',
click: () => saveTorrentFileAs(torrentSummary)
}))
menu.popup(electron.remote.getCurrentWindow())
}
function showItemInFolder (torrentSummary) {
var itemPath = path.join(torrentSummary.path, torrentSummary.files[0].path)
if (torrentSummary.files.length > 1) {
itemPath = path.dirname(itemPath)
}
ipcRenderer.send('showItemInFolder', itemPath)
}
function saveTorrentFileAs (torrentSummary) {
var newFileName = `${path.parse(torrentSummary.name).name}.torrent`
var opts = {
title: 'Save Torrent File',
defaultPath: path.join(state.saved.downloadPath, newFileName),
filters: [
{ name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] }
]
}
electron.remote.dialog.showSaveDialog(electron.remote.getCurrentWindow(), opts, function (savePath) {
var torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, function (err, torrentFile) {
if (err) return onError(err)
fs.writeFile(savePath, torrentFile, function (err) {
if (err) return onError(err)
})
})
})
}
// Set window dimensions to match video dimensions or fill the screen
function setDimensions (dimensions) {
// Don't modify the window size if it's already maximized
if (electron.remote.getCurrentWindow().isMaximized()) {
state.window.bounds = null
return
}
// Save the bounds of the window for later. See restoreBounds()
state.window.bounds = {
x: window.screenX,
y: window.screenY,
width: window.outerWidth,
height: window.outerHeight
}
state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
// Limit window size to screen size
var screenWidth = window.screen.width
var screenHeight = window.screen.height
var aspectRatio = dimensions.width / dimensions.height
var scaleFactor = Math.min(
Math.min(screenWidth / dimensions.width, 1),
Math.min(screenHeight / dimensions.height, 1)
)
var width = Math.max(
Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH
)
var height = Math.max(
Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT
)
ipcRenderer.send('setAspectRatio', aspectRatio)
ipcRenderer.send('setBounds', {x: null, y: null, width, height})
state.playing.aspectRatio = aspectRatio
}
function restoreBounds () {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}
function showDoneNotification (torrent) {
var notif = new window.Notification('Download Complete', {
body: torrent.name,
silent: true
})
notif.onclick = function () {
ipcRenderer.send('focusWindow', 'main')
}
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.current().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
}
// Event handlers
function onError (err) {
console.error(err.stack || err)
sound.play('ERROR')
state.errors.push({
time: new Date().getTime(),
message: err.message || err
})
update()
}
function onWarning (err) {
console.log('warning: %s', err.message || err)
}
function onPaste (e) {
if (e.target.tagName.toLowerCase() === 'input') return
var torrentIds = electron.clipboard.readText().split('\n')
torrentIds.forEach(function (torrentId) {
torrentId = torrentId.trim()
if (torrentId.length === 0) return
dispatch('addTorrent', torrentId)
})
}
function onFocus (e) {
state.window.isFocused = true
state.dock.badge = 0
update()
}
function onBlur () {
state.window.isFocused = false
update()
}