WebTorrent process
* Separate hidden window, with its own renderer process, for WebTorrent (Must be a window. You cannot run WebRTC at all in a Web Worker, and you can't run it well in a node process like the electron main process.) * Disabled the create-torrent-modal for now. That gives us a consistent UX regardless of whether the user dragged files or folders onto the app or opened the Create New Torrent menu item. * Main process routes all messages between the main and webtorrent windows. * The renderer index.js is smaller now (but still too big), with the WebTorrent interface moved to webtorrent.js / it's own process. * The UI should be faster now, and should not lag under load.
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
console.time('init')
|
||||
|
||||
var cfg = require('application-config')('WebTorrent')
|
||||
var defaultAnnounceList = require('create-torrent').announceList
|
||||
var dragDrop = require('drag-drop')
|
||||
var electron = require('electron')
|
||||
var EventEmitter = require('events')
|
||||
var fs = require('fs')
|
||||
var mainLoop = require('main-loop')
|
||||
var mkdirp = require('mkdirp')
|
||||
var musicmetadata = require('musicmetadata')
|
||||
var networkAddress = require('network-address')
|
||||
var path = require('path')
|
||||
var remote = require('remote')
|
||||
|
||||
@@ -21,14 +17,11 @@ var App = require('./views/app')
|
||||
var errors = require('./lib/errors')
|
||||
var config = require('../config')
|
||||
var TorrentPlayer = require('./lib/torrent-player')
|
||||
var torrentPoster = require('./lib/torrent-poster')
|
||||
var util = require('./util')
|
||||
var {setDispatch} = require('./lib/dispatcher')
|
||||
setDispatch(dispatch)
|
||||
|
||||
// These two dependencies are the slowest-loading, so we lazy load them
|
||||
// This cuts time from icon click to rendered window from ~550ms to ~150ms on my laptop
|
||||
var WebTorrent = null
|
||||
// This dependency is the slowest-loading, so we lazy load it
|
||||
var Cast = null
|
||||
|
||||
// Electron apps have two processes: a main process (node) runs first and starts
|
||||
@@ -43,11 +36,6 @@ var dialog = remote.require('dialog')
|
||||
// For easy debugging in Developer Tools
|
||||
var state = global.state = require('./state')
|
||||
|
||||
// Force use of webtorrent trackers on all torrents
|
||||
global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
|
||||
.map((arr) => arr[0])
|
||||
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
|
||||
|
||||
var vdomLoop
|
||||
|
||||
// All state lives in state.js. `state.saved` is read from and written to a file.
|
||||
@@ -66,10 +54,8 @@ function init () {
|
||||
state.location.go({ url: 'home' })
|
||||
|
||||
// Lazily load the WebTorrent, Chromecast, and Airplay modules
|
||||
window.setTimeout(function () {
|
||||
lazyLoadClient()
|
||||
lazyLoadCast()
|
||||
}, 750)
|
||||
initWebtorrent()
|
||||
window.setTimeout(lazyLoadCast, 750)
|
||||
|
||||
// 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
|
||||
@@ -125,12 +111,6 @@ function init () {
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
// Lazily loads the WebTorrent module and creates the WebTorrent client
|
||||
function lazyLoadClient () {
|
||||
if (!WebTorrent) initWebtorrent()
|
||||
return state.client
|
||||
}
|
||||
|
||||
// Lazily loads Chromecast and Airplay support
|
||||
function lazyLoadCast () {
|
||||
if (!Cast) {
|
||||
@@ -140,41 +120,25 @@ function lazyLoadCast () {
|
||||
return Cast
|
||||
}
|
||||
|
||||
// Load the WebTorrent module, connect to both the WebTorrent and BitTorrent
|
||||
// networks, resume torrents, start monitoring torrent progress
|
||||
// Talk to WebTorrent process, resume torrents, start monitoring torrent progress
|
||||
function initWebtorrent () {
|
||||
WebTorrent = require('webtorrent')
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
|
||||
// client, as explained here: https://webtorrent.io/faq
|
||||
state.client = new WebTorrent()
|
||||
state.client.on('warning', onWarning)
|
||||
state.client.on('error', function (err) {
|
||||
// TODO: WebTorrent should have semantic errors
|
||||
if (err.message.startsWith('There is already a swarm')) {
|
||||
onError(new Error('Couldn\'t add duplicate torrent'))
|
||||
} else {
|
||||
onError(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Restart everything we were torrenting last time the app ran
|
||||
resumeTorrents()
|
||||
|
||||
// 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(function () {
|
||||
if (!updateTorrentProgress()) {
|
||||
update() // If we didn't just update(), do so now, for the video cursor
|
||||
}
|
||||
}, 1000)
|
||||
setInterval(update, 1000)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return App(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.
|
||||
@@ -217,16 +181,8 @@ function dispatch (action, ...args) {
|
||||
if (action === 'showOpenTorrentFile') {
|
||||
ipcRenderer.send('showOpenTorrentFile') /* open torrent file */
|
||||
}
|
||||
if (action === 'seed') {
|
||||
// TODO: right now, creating a torrent thru File > Create New Torrent
|
||||
// creates and starts seeding a new torrent directly, while dragging files
|
||||
// or folders onto the app opens the create-torrent-modal
|
||||
//
|
||||
// That's because the former gets a single string and the latter gets a list
|
||||
// of W3C File objects. We should fix this inconsitency, ideally without
|
||||
// duping this code in the drag-drop module:
|
||||
// https://github.com/feross/drag-drop/blob/master/index.js
|
||||
createTorrent({files: args[0]} /* file or folder path */)
|
||||
if (action === 'createTorrent') {
|
||||
createTorrent(args[0] /* options */)
|
||||
}
|
||||
if (action === 'openFile') {
|
||||
openFile(args[0] /* infoHash */, args[1] /* index */)
|
||||
@@ -321,14 +277,11 @@ function dispatch (action, ...args) {
|
||||
if (action === 'skipVersion') {
|
||||
if (!state.saved.skippedVersions) state.saved.skippedVersions = []
|
||||
state.saved.skippedVersions.push(args[0] /* version */)
|
||||
saveState()
|
||||
saveStateThrottled()
|
||||
}
|
||||
if (action === 'saveState') {
|
||||
saveState()
|
||||
}
|
||||
if (action === 'createTorrent') {
|
||||
createTorrent(args[0] /* options */)
|
||||
}
|
||||
|
||||
// Update the virtual-dom, unless it's just a mouse move event
|
||||
if (action !== 'mediaMouseMoved') {
|
||||
@@ -404,6 +357,19 @@ function setupIpc () {
|
||||
state.devices[device] = player
|
||||
update()
|
||||
})
|
||||
|
||||
ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args))
|
||||
ipcRenderer.on('wt-ready', (e, ...args) => torrentReady(...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
|
||||
@@ -429,24 +395,46 @@ function resumeTorrents () {
|
||||
.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 ' + cfg.filePath)
|
||||
|
||||
// Clean up, so that we're not saving any pending state
|
||||
var copy = JSON.parse(JSON.stringify(state.saved))
|
||||
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)
|
||||
copy.torrents.forEach(function (x) {
|
||||
if (x.playStatus !== 'unplayable') delete x.playStatus
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
cfg.write(copy, function (err) {
|
||||
if (err) console.error(err)
|
||||
ipcRenderer.send('savedState')
|
||||
update()
|
||||
})
|
||||
|
||||
// Update right away, don't wait for the state to save
|
||||
update()
|
||||
}
|
||||
|
||||
function onOpen (files) {
|
||||
@@ -454,11 +442,11 @@ function onOpen (files) {
|
||||
|
||||
// .torrent file = start downloading the torrent
|
||||
files.filter(isTorrent).forEach(function (torrentFile) {
|
||||
addTorrent(torrentFile)
|
||||
addTorrent(torrentFile.path)
|
||||
})
|
||||
|
||||
// everything else = seed these files
|
||||
showCreateTorrentModal(files.filter(isNotTorrent))
|
||||
createTorrentFromFileObjects(files)
|
||||
}
|
||||
|
||||
function onPaste (e) {
|
||||
@@ -485,316 +473,233 @@ function isNotTorrent (file) {
|
||||
|
||||
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
||||
// Returns undefined if we don't know that infoHash
|
||||
function getTorrentSummary (infoHash) {
|
||||
if (!infoHash) return undefined
|
||||
return state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
// Get an active torrent from state.client.torrents
|
||||
// Returns undefined if we are not currently torrenting that infoHash
|
||||
function getTorrent (infoHash) {
|
||||
if (!infoHash) return undefined
|
||||
var pending = state.pendingTorrents[infoHash]
|
||||
if (pending) return pending
|
||||
return lazyLoadClient().torrents.find((x) => x.infoHash === 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 torrent = startTorrentingID(torrentId)
|
||||
torrent.on('infoHash', function () {
|
||||
addTorrentToList(torrent)
|
||||
})
|
||||
}
|
||||
|
||||
function addTorrentToList (torrent) {
|
||||
if (getTorrentSummary(torrent.infoHash)) {
|
||||
return // Skip, torrent is already in state.saved
|
||||
}
|
||||
|
||||
var torrentSummary = {
|
||||
status: 'new',
|
||||
name: torrent.name
|
||||
}
|
||||
state.saved.torrents.push(torrentSummary)
|
||||
playInterfaceSound('ADD')
|
||||
|
||||
// If torrentId is a remote torrent (filesystem path, http url, etc.), wait for
|
||||
// WebTorrent to finish reading it
|
||||
if (torrent.infoHash) onInfoHash()
|
||||
else torrent.on('infoHash', onInfoHash)
|
||||
|
||||
function onInfoHash () {
|
||||
torrentSummary.infoHash = torrent.infoHash
|
||||
torrentSummary.magnetURI = torrent.magnetURI
|
||||
saveState()
|
||||
update()
|
||||
}
|
||||
var torrentKey = state.nextTorrentKey++
|
||||
var path = state.saved.downloadPath
|
||||
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
|
||||
}
|
||||
|
||||
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
|
||||
function startTorrentingSummary (torrentSummary) {
|
||||
var s = torrentSummary
|
||||
if (s.torrentPath) {
|
||||
var torrentPath = util.getAbsoluteStaticPath(s.torrentPath)
|
||||
var ret = startTorrentingID(torrentPath, s.path, s.fileModtimes)
|
||||
if (s.infoHash) state.pendingTorrents[s.infoHash] = ret
|
||||
return ret
|
||||
} else if (s.magnetURI) {
|
||||
return startTorrentingID(s.magnetURI, s.path, s.fileModtimes)
|
||||
} else {
|
||||
return startTorrentingID(s.infoHash, s.path, s.fileModtimes)
|
||||
|
||||
// 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.torrentPath) { // Load torrent file from disk
|
||||
torrentID = util.getAbsoluteStaticPath(s.torrentPath)
|
||||
} else { // Load torrent from DHT
|
||||
torrentID = s.magnetURI || s.infoHash
|
||||
}
|
||||
|
||||
ipcRenderer.send('wt-start-torrenting', s.torrentKey, torrentID, path, s.fileModtimes)
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object
|
||||
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
function startTorrentingID (torrentID, path, fileModtimes) {
|
||||
console.log('starting torrent ' + torrentID)
|
||||
var torrent = lazyLoadClient().add(torrentID, {
|
||||
path: path || state.saved.downloadPath, // Use downloads folder
|
||||
fileModtimes: fileModtimes
|
||||
})
|
||||
addTorrentEvents(torrent)
|
||||
return torrent
|
||||
}
|
||||
// TODO: maybe have a "create torrent" modal in the future, with options like
|
||||
// custom trackers, private flag, and so on?
|
||||
//
|
||||
// Right now create-torrent-modal is v basic, only user input is OK / Cancel
|
||||
//
|
||||
// Also, if you uncomment below below, creating a torrent thru
|
||||
// File > Create New Torrent will still create a new torrent directly, while
|
||||
// dragging files or folders onto the app opens the create-torrent-modal
|
||||
//
|
||||
// That's because the former gets a single string and the latter gets a list
|
||||
// of W3C File objects. We should fix this inconsistency, ideally without
|
||||
// duping this code in the drag-drop module:
|
||||
// https://github.com/feross/drag-drop/blob/master/index.js
|
||||
//
|
||||
// function showCreateTorrentModal (files) {
|
||||
// if (files.length === 0) return
|
||||
// state.modal = {
|
||||
// id: 'create-torrent-modal',
|
||||
// files: files
|
||||
// }
|
||||
// }
|
||||
|
||||
// Stops downloading and/or seeding
|
||||
function stopTorrenting (infoHash) {
|
||||
var torrent = getTorrent(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
}
|
||||
//
|
||||
// TORRENT MANAGEMENT
|
||||
// Send commands to the WebTorrent process, handle events
|
||||
//
|
||||
|
||||
// Prompts the user to create a torrent for a local file or folder
|
||||
function showCreateTorrentModal (files) {
|
||||
if (files.length === 0) return
|
||||
state.modal = {
|
||||
id: 'create-torrent-modal',
|
||||
files: files
|
||||
// Creates a new torrent from a drag-dropped file or folder
|
||||
function createTorrentFromFileObjects (files) {
|
||||
var filePaths = (files
|
||||
.filter(isNotTorrent)
|
||||
.map((x) => x.path))
|
||||
|
||||
// Single-file torrents are easy. Multi-file torrents require special handling
|
||||
// make sure WebTorrent seeds all files in place, without copying to /tmp
|
||||
if (filePaths.length === 1) {
|
||||
return createTorrent({files: filePaths[0]})
|
||||
}
|
||||
|
||||
// First, extract the base folder that the files are all in
|
||||
var pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
|
||||
if (files.length > 0 && !pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
|
||||
pathPrefix = path.dirname(pathPrefix)
|
||||
}
|
||||
|
||||
// Then, use the name of the base folder (or sole file, for a single file torrent)
|
||||
// as the default name. Show all files relative to the base folder.
|
||||
var defaultName = path.basename(pathPrefix)
|
||||
var basePath = path.dirname(pathPrefix)
|
||||
var options = {
|
||||
// TODO: we can't let the user choose their own name if we want WebTorrent
|
||||
// to use the files in place rather than creating a new folder.
|
||||
name: defaultName,
|
||||
path: basePath,
|
||||
files: filePaths
|
||||
}
|
||||
|
||||
createTorrent(options)
|
||||
}
|
||||
|
||||
// Creates a new torrent and start seeeding
|
||||
function createTorrent (options) {
|
||||
var torrent = lazyLoadClient().seed(options.files, options)
|
||||
addTorrentToList(torrent)
|
||||
addTorrentEvents(torrent)
|
||||
var torrentKey = state.nextTorrentKey++
|
||||
ipcRenderer.send('wt-create-torrent', torrentKey, options)
|
||||
}
|
||||
|
||||
function addTorrentEvents (torrent) {
|
||||
torrent.on('infoHash', function () {
|
||||
var infoHash = torrent.infoHash
|
||||
if (state.pendingTorrents[infoHash]) delete state.pendingTorrents[infoHash]
|
||||
update()
|
||||
})
|
||||
torrent.on('ready', torrentReady)
|
||||
torrent.on('done', torrentDone)
|
||||
torrent.on('warning', onWarning)
|
||||
torrent.on('error', torrentError)
|
||||
function torrentInfoHash (torrentKey, infoHash) {
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
console.log('got infohash for %s torrent %s',
|
||||
torrentSummary ? 'existing' : 'new', torrentKey)
|
||||
|
||||
function torrentError (err) {
|
||||
if (!torrentSummary) {
|
||||
torrentSummary = {
|
||||
torrentKey: torrentKey,
|
||||
status: 'new'
|
||||
}
|
||||
state.saved.torrents.push(torrentSummary)
|
||||
playInterfaceSound('ADD')
|
||||
}
|
||||
|
||||
torrentSummary.infoHash = infoHash
|
||||
update()
|
||||
}
|
||||
|
||||
function torrentWarning (torrentKey, message) {
|
||||
onWarning(message)
|
||||
}
|
||||
|
||||
function torrentError (torrentKey, message) {
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
|
||||
// TODO: WebTorrent should have semantic errors
|
||||
if (message.startsWith('There is already a swarm')) {
|
||||
onError(new Error('Couldn\'t add duplicate torrent'))
|
||||
} else if (!torrentSummary) {
|
||||
onError(message)
|
||||
} else {
|
||||
console.log('error, stopping torrent %s (%s):\n\t%o',
|
||||
torrent.name, torrent.infoHash, err.message)
|
||||
// TODO: update torrentSummary, even if it doesn't have an infohash yet
|
||||
if (torrent.infoHash) {
|
||||
getTorrentSummary(torrent.infoHash).status = 'paused'
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
function torrentReady () {
|
||||
// Summarize torrent
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
torrentSummary.status = 'downloading'
|
||||
torrentSummary.ready = true
|
||||
torrentSummary.name = torrentSummary.displayName || torrent.name
|
||||
torrentSummary.path = torrent.path
|
||||
|
||||
// Summarize torrent files
|
||||
torrentSummary.files = torrent.files.map(summarizeFileInTorrent)
|
||||
updateTorrentProgress()
|
||||
|
||||
// Save the .torrent file, if it hasn't been saved already
|
||||
if (!torrentSummary.torrentPath) saveTorrentFile(torrentSummary, torrent)
|
||||
|
||||
// Auto-generate a poster image, if it hasn't been generated already
|
||||
if (!torrentSummary.posterURL) generateTorrentPoster(torrent, torrentSummary)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
// Update the torrent summary
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
torrentSummary.status = 'seeding'
|
||||
updateTorrentProgress()
|
||||
torrent.getFileModtimes(function (err, fileModtimes) {
|
||||
if (err) return onError(err)
|
||||
torrentSummary.fileModtimes = fileModtimes
|
||||
saveState()
|
||||
})
|
||||
|
||||
// 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 (torrent.received > 0) {
|
||||
if (!state.window.isFocused) {
|
||||
state.dock.badge += 1
|
||||
}
|
||||
showDoneNotification(torrent)
|
||||
}
|
||||
|
||||
torrentSummary.name, torrentSummary.infoHash, message)
|
||||
torrentSummary.status = 'paused'
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
var changed = false
|
||||
function torrentReady (torrentKey, torrentInfo) {
|
||||
// Summarize torrent
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
torrentSummary.status = 'downloading'
|
||||
torrentSummary.ready = true
|
||||
torrentSummary.name = torrentSummary.displayName || torrentInfo.name
|
||||
torrentSummary.path = torrentInfo.path
|
||||
torrentSummary.files = torrentInfo.files
|
||||
update()
|
||||
|
||||
// Save the .torrent file, if it hasn't been saved already
|
||||
if (!torrentSummary.torrentPath) ipcRenderer.send('wt-save-torrent-file', torrentKey)
|
||||
|
||||
// Auto-generate a poster image, if it hasn't been generated already
|
||||
if (!torrentSummary.posterURL) 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(torrentKey)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function torrentProgress (progressInfo) {
|
||||
// Overall progress across all active torrents, 0 to 1
|
||||
var progress = progressInfo.progress
|
||||
var hasActiveTorrents = progressInfo.hasActiveTorrents
|
||||
|
||||
// First, track overall progress
|
||||
var progress = lazyLoadClient().progress
|
||||
var activeTorrentsExist = lazyLoadClient().torrents.some(function (torrent) {
|
||||
return torrent.progress !== 1
|
||||
})
|
||||
// Hide progress bar when client has no torrents, or progress is 100%
|
||||
if (!activeTorrentsExist || progress === 1) {
|
||||
// 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
|
||||
if (state.dock.progress !== progress) changed = true
|
||||
state.dock.progress = progress
|
||||
|
||||
// Track progress for every file in each torrentSummary
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
lazyLoadClient().torrents.forEach(function (torrent) {
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
if (!torrentSummary || !torrent.ready) return
|
||||
torrent.files.forEach(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
for (var piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
|
||||
var fileSummary = torrentSummary.files[index]
|
||||
if (fileSummary.numPiecesPresent !== numPiecesPresent || fileSummary.numPieces !== numPieces) {
|
||||
fileSummary.numPieces = numPieces
|
||||
fileSummary.numPiecesPresent = numPiecesPresent
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (changed) update()
|
||||
return changed
|
||||
}
|
||||
|
||||
function generateTorrentPoster (torrent, torrentSummary) {
|
||||
torrentPoster(torrent, function (err, buf, extension) {
|
||||
if (err) return onWarning(err)
|
||||
// save it for next time
|
||||
mkdirp(config.CONFIG_POSTER_PATH, function (err) {
|
||||
if (err) return onWarning(err)
|
||||
var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension)
|
||||
fs.writeFile(posterFilePath, buf, function (err) {
|
||||
if (err) return onWarning(err)
|
||||
// show the poster
|
||||
torrentSummary.posterURL = posterFilePath
|
||||
update()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a file in a torrent
|
||||
function summarizeFileInTorrent (file) {
|
||||
return {
|
||||
name: file.name,
|
||||
length: file.length,
|
||||
numPiecesPresent: 0,
|
||||
numPieces: null
|
||||
}
|
||||
}
|
||||
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we never
|
||||
// have to download it again. Never ask the DHT the same question twice.
|
||||
function saveTorrentFile (torrentSummary, torrent) {
|
||||
checkIfTorrentFileExists(torrentSummary.infoHash, function (torrentPath, exists) {
|
||||
if (exists) {
|
||||
// We've already saved the file
|
||||
torrentSummary.torrentPath = torrentPath
|
||||
saveState()
|
||||
// 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
|
||||
}
|
||||
|
||||
// Otherwise, save the .torrent file, under the app config folder
|
||||
fs.mkdir(config.CONFIG_TORRENT_PATH, function (_) {
|
||||
fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
|
||||
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
|
||||
console.log('saved torrent file %s', torrentPath)
|
||||
torrentSummary.torrentPath = torrentPath
|
||||
saveState()
|
||||
})
|
||||
})
|
||||
torrentSummary.progress = p
|
||||
})
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
// Checks whether we've already resolved a given infohash to a torrent file
|
||||
// Calls back with (torrentPath, exists). Logs, does not call back on error
|
||||
function checkIfTorrentFileExists (infoHash, cb) {
|
||||
var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent')
|
||||
fs.exists(torrentPath, function (exists) {
|
||||
cb(torrentPath, exists)
|
||||
})
|
||||
function torrentFileModtimes (torrentKey, fileModtimes) {
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
torrentSummary.fileModtimes = fileModtimes
|
||||
saveStateThrottled()
|
||||
}
|
||||
|
||||
function startServer (torrentSummary, index, cb) {
|
||||
if (state.server) return cb()
|
||||
|
||||
var torrent = getTorrent(torrentSummary.infoHash)
|
||||
if (!torrent) torrent = startTorrentingSummary(torrentSummary)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent, index, cb)
|
||||
else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index, cb))
|
||||
function torrentFileSaved (torrentKey, torrentPath) {
|
||||
console.log('torrent file saved %s: %s', torrentKey, torrentPath)
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
torrentSummary.torrentPath = torrentPath
|
||||
saveStateThrottled()
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
// automatically choose which file in the torrent to play, if necessary
|
||||
if (index === undefined) index = pickFileToPlay(torrent.files)
|
||||
if (index === undefined) return cb(new errors.UnplayableError())
|
||||
var file = torrent.files[index]
|
||||
function torrentPosterSaved (torrentKey, posterPath) {
|
||||
var torrentSummary = getTorrentSummary(torrentKey)
|
||||
torrentSummary.posterURL = posterPath
|
||||
saveStateThrottled()
|
||||
}
|
||||
|
||||
// update state
|
||||
state.playing.infoHash = torrent.infoHash
|
||||
state.playing.fileIndex = index
|
||||
state.playing.type = TorrentPlayer.isVideo(file) ? 'video'
|
||||
: TorrentPlayer.isAudio(file) ? 'audio'
|
||||
: 'other'
|
||||
|
||||
// if it's audio, parse out the metadata (artist, title, etc)
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
function torrentAudioMetadata (infoHash, index, info) {
|
||||
var torrentSummary = getTorrentSummary(infoHash)
|
||||
var fileSummary = torrentSummary.files[index]
|
||||
if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
|
||||
musicmetadata(file.createReadStream(), function (err, info) {
|
||||
if (err) return
|
||||
console.log('got audio metadata for %s: %o', file.name, info)
|
||||
fileSummary.audioInfo = info
|
||||
update()
|
||||
})
|
||||
}
|
||||
fileSummary.audioInfo = info
|
||||
update()
|
||||
}
|
||||
|
||||
// either way, start a streaming torrent-to-http server
|
||||
var server = torrent.createServer()
|
||||
server.listen(0, function () {
|
||||
var port = server.address().port
|
||||
var urlSuffix = ':' + port + '/' + index
|
||||
state.server = {
|
||||
server: server,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix
|
||||
}
|
||||
cb()
|
||||
})
|
||||
function torrentServerRunning (serverInfo) {
|
||||
state.server = serverInfo
|
||||
}
|
||||
|
||||
// Picks the default file to play from a list of torrent or torrentSummary files
|
||||
@@ -820,18 +725,22 @@ function pickFileToPlay (files) {
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!state.server) return
|
||||
state.server.server.destroy()
|
||||
state.server = null
|
||||
ipcRenderer.send('wt-stop-server')
|
||||
state.playing.infoHash = null
|
||||
state.playing.fileIndex = null
|
||||
state.server = null
|
||||
}
|
||||
|
||||
// Opens the video player
|
||||
function openPlayer (infoHash, index, cb) {
|
||||
var torrentSummary = getTorrentSummary(infoHash)
|
||||
var torrent = lazyLoadClient().get(infoHash)
|
||||
if (!torrent || !torrent.done) playInterfaceSound('PLAY')
|
||||
|
||||
// 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) playInterfaceSound('PLAY')
|
||||
torrentSummary.playStatus = 'requested'
|
||||
update()
|
||||
|
||||
@@ -842,14 +751,43 @@ function openPlayer (infoHash, index, cb) {
|
||||
update()
|
||||
}, 10000) /* give it a few seconds */
|
||||
|
||||
startServer(torrentSummary, index, function (err) {
|
||||
if (['downloading', 'seeding'].includes(torrentSummary.status)) {
|
||||
openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)
|
||||
} else {
|
||||
startTorrentingSummary(torrentSummary)
|
||||
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
|
||||
() => 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)
|
||||
|
||||
/**
|
||||
TODO: where do we know if it's unplayable?
|
||||
if (err) {
|
||||
torrentSummary.playStatus = 'unplayable'
|
||||
playInterfaceSound('ERROR')
|
||||
update()
|
||||
return onError(err)
|
||||
}
|
||||
*/
|
||||
|
||||
// if we timed out (user clicked play a long time ago), don't autoplay
|
||||
var timedOut = torrentSummary.playStatus === 'timeout'
|
||||
@@ -884,28 +822,25 @@ function closePlayer (cb) {
|
||||
}
|
||||
|
||||
function openFile (infoHash, index) {
|
||||
var torrent = lazyLoadClient().get(infoHash)
|
||||
if (!torrent) return
|
||||
|
||||
var filePath = path.join(torrent.path, torrent.files[index].path)
|
||||
var torrentSummary = getTorrentSummary(infoHash)
|
||||
var filePath = path.join(
|
||||
torrentSummary.path,
|
||||
torrentSummary.files[index].path)
|
||||
ipcRenderer.send('openItem', filePath)
|
||||
}
|
||||
|
||||
function openFolder (infoHash) {
|
||||
var torrent = lazyLoadClient().get(infoHash)
|
||||
if (!torrent) return
|
||||
var torrentSummary = getTorrentSummary(infoHash)
|
||||
|
||||
var folderPath = path.join(torrent.path, torrent.name)
|
||||
// Multi-file torrents create their own folder, single file torrents just
|
||||
// drop the file directly into the Downloads folder
|
||||
fs.stat(folderPath, function (err, stats) {
|
||||
if (err || !stats.isDirectory()) {
|
||||
folderPath = torrent.path
|
||||
}
|
||||
ipcRenderer.send('openItem', folderPath)
|
||||
})
|
||||
var firstFilePath = path.join(
|
||||
torrentSummary.path,
|
||||
torrentSummary.files[0].path)
|
||||
var folderPath = path.dirname(firstFilePath)
|
||||
|
||||
ipcRenderer.send('openItem', folderPath)
|
||||
}
|
||||
|
||||
// TODO: use torrentKey, not infoHash
|
||||
function toggleTorrent (infoHash) {
|
||||
var torrentSummary = getTorrentSummary(infoHash)
|
||||
if (torrentSummary.status === 'paused') {
|
||||
@@ -914,18 +849,18 @@ function toggleTorrent (infoHash) {
|
||||
playInterfaceSound('ENABLE')
|
||||
} else {
|
||||
torrentSummary.status = 'paused'
|
||||
stopTorrenting(torrentSummary.infoHash)
|
||||
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
|
||||
playInterfaceSound('DISABLE')
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use torrentKey, not infoHash
|
||||
function deleteTorrent (infoHash) {
|
||||
var torrent = getTorrent(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
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)
|
||||
saveState()
|
||||
saveStateThrottled()
|
||||
state.location.clearForward() // prevent user from going forward to a deleted torrent
|
||||
playInterfaceSound('DELETE')
|
||||
}
|
||||
@@ -1032,7 +967,7 @@ function onError (err) {
|
||||
}
|
||||
|
||||
function onWarning (err) {
|
||||
console.log('warning: %s', err.message)
|
||||
console.log('warning: %s', err.message || err)
|
||||
}
|
||||
|
||||
function showDoneNotification (torrent) {
|
||||
@@ -1067,3 +1002,13 @@ function setupCrashReporter () {
|
||||
submitURL: config.CRASH_REPORT_URL
|
||||
})
|
||||
}
|
||||
|
||||
// Finds the longest common prefix
|
||||
function findCommonPrefix (a, b) {
|
||||
for (var i = 0; i < a.length && i < b.length; i++) {
|
||||
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
|
||||
}
|
||||
if (i === a.length) return a
|
||||
if (i === b.length) return b
|
||||
return a.substring(0, i)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ module.exports = {
|
||||
lastTimeUpdate: 0, /* Unix time in ms */
|
||||
mouseStationarySince: 0 /* Unix time in ms */
|
||||
},
|
||||
pendingTorrents: {}, /* infohash to WebTorrent handle */
|
||||
devices: { /* playback devices like Chromecast and AppleTV */
|
||||
airplay: null, /* airplay client. finds and manages AppleTVs */
|
||||
chromecast: null /* chromecast client. finds and manages Chromecasts */
|
||||
@@ -43,6 +42,7 @@ module.exports = {
|
||||
},
|
||||
modal: null, /* modal popover */
|
||||
errors: [], /* user-facing errors */
|
||||
nextTorrentKey: 1, /* identify torrents for IPC between the main and webtorrent windows */
|
||||
|
||||
/*
|
||||
* Saved state is read from and written to a file every time the app runs.
|
||||
|
||||
@@ -5,6 +5,7 @@ var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
|
||||
var prettyBytes = require('prettier-bytes')
|
||||
var Bitfield = require('bitfield')
|
||||
|
||||
var util = require('../util')
|
||||
var {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
@@ -20,7 +21,7 @@ function Player (state) {
|
||||
onmousemove=${dispatcher('mediaMouseMoved')}>
|
||||
${showVideo ? renderMedia(state) : renderCastScreen(state)}
|
||||
${renderPlayerControls(state)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -161,18 +162,20 @@ function renderLoadingSpinner (state) {
|
||||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000)
|
||||
if (!isProbablyStalled) return
|
||||
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
var torrent = state.client.get(torrentSummary.infoHash)
|
||||
var file = torrentSummary.files[state.playing.fileIndex]
|
||||
var progress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
|
||||
var prog = getPlayingTorrentSummary(state).progress || {}
|
||||
var fileProgress = 0
|
||||
if (prog.files) {
|
||||
var file = prog.files[state.playing.fileIndex]
|
||||
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
|
||||
}
|
||||
|
||||
return hx`
|
||||
<div class='media-stalled'>
|
||||
<div class='loading-spinner'> </div>
|
||||
<div class='loading-status ellipsis'>
|
||||
<span class='progress'>${progress}%</span> downloaded,
|
||||
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span>
|
||||
<span class='progress'>${fileProgress}%</span> downloaded,
|
||||
<span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -203,25 +206,6 @@ function renderCastScreen (state) {
|
||||
`
|
||||
}
|
||||
|
||||
// Returns the CSS background-image string for a poster image + dark vignette
|
||||
function cssBackgroundImagePoster (state) {
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
if (!torrentSummary || !torrentSummary.posterURL) return ''
|
||||
var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL)
|
||||
var cleanURL = posterURL.replace(/\\/g, '/')
|
||||
return cssBackgroundImageDarkGradient() + `, url(${cleanURL})`
|
||||
}
|
||||
|
||||
function cssBackgroundImageDarkGradient () {
|
||||
return 'radial-gradient(circle at center, ' +
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
|
||||
}
|
||||
|
||||
function getPlayingTorrentSummary (state) {
|
||||
var infoHash = state.playing.infoHash
|
||||
return state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
function renderPlayerControls (state) {
|
||||
var positionPercent = 100 * state.playing.currentTime / state.playing.duration
|
||||
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' }
|
||||
@@ -340,24 +324,24 @@ function renderPlayerControls (state) {
|
||||
// Renders the loading bar. Shows which parts of the torrent are loaded, which
|
||||
// can be "spongey" / non-contiguous
|
||||
function renderLoadingBar (state) {
|
||||
var torrent = state.client.get(state.playing.infoHash)
|
||||
if (torrent === null) {
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
if (!torrentSummary.progress) {
|
||||
return []
|
||||
}
|
||||
var file = torrent.files[state.playing.fileIndex]
|
||||
|
||||
// Find all contiguous parts of the torrent which are loaded
|
||||
var prog = torrentSummary.progress
|
||||
var fileProg = prog.files[state.playing.fileIndex]
|
||||
var parts = []
|
||||
var lastPartPresent = false
|
||||
var numParts = file._endPiece - file._startPiece + 1
|
||||
for (var i = file._startPiece; i <= file._endPiece; i++) {
|
||||
var partPresent = torrent.bitfield.get(i)
|
||||
if (partPresent && !lastPartPresent) {
|
||||
parts.push({start: i - file._startPiece, count: 1})
|
||||
var lastPiecePresent = false
|
||||
for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
|
||||
var partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
|
||||
if (partPresent && !lastPiecePresent) {
|
||||
parts.push({start: i - fileProg.startPiece, count: 1})
|
||||
} else if (partPresent) {
|
||||
parts[parts.length - 1].count++
|
||||
}
|
||||
lastPartPresent = partPresent
|
||||
lastPiecePresent = partPresent
|
||||
}
|
||||
|
||||
// Output some bars to show which parts of the file are loaded
|
||||
@@ -365,8 +349,8 @@ function renderLoadingBar (state) {
|
||||
<div class='loading-bar'>
|
||||
${parts.map(function (part) {
|
||||
var style = {
|
||||
left: (100 * part.start / numParts) + '%',
|
||||
width: (100 * part.count / numParts) + '%'
|
||||
left: (100 * part.start / fileProg.numPieces) + '%',
|
||||
width: (100 * part.count / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
return hx`<div class='loading-bar-part' style=${style}></div>`
|
||||
@@ -374,3 +358,22 @@ function renderLoadingBar (state) {
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Returns the CSS background-image string for a poster image + dark vignette
|
||||
function cssBackgroundImagePoster (state) {
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
if (!torrentSummary || !torrentSummary.posterURL) return ''
|
||||
var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL)
|
||||
var cleanURL = posterURL.replace(/\\/g, '/')
|
||||
return cssBackgroundImageDarkGradient() + `, url(${cleanURL})`
|
||||
}
|
||||
|
||||
function cssBackgroundImageDarkGradient () {
|
||||
return 'radial-gradient(circle at center, ' +
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
|
||||
}
|
||||
|
||||
function getPlayingTorrentSummary (state) {
|
||||
var infoHash = state.playing.infoHash
|
||||
return state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ function TorrentList (state) {
|
||||
function renderTorrent (torrentSummary) {
|
||||
// Get ephemeral data (like progress %) directly from the WebTorrent handle
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var torrent = state.client
|
||||
? state.client.torrents.find((x) => x.infoHash === infoHash)
|
||||
: null
|
||||
var isSelected = infoHash && state.selectedInfoHash === infoHash
|
||||
|
||||
// Background image: show some nice visuals, like a frame from the movie, if possible
|
||||
@@ -57,34 +54,34 @@ function TorrentList (state) {
|
||||
<div style=${style} class=${classes}
|
||||
oncontextmenu=${infoHash && dispatcher('openTorrentContextMenu', infoHash)}
|
||||
onclick=${infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
|
||||
${renderTorrentMetadata(torrent, torrentSummary)}
|
||||
${renderTorrentMetadata(torrentSummary)}
|
||||
${infoHash ? renderTorrentButtons(torrentSummary) : ''}
|
||||
${isSelected ? renderTorrentDetails(torrent, torrentSummary) : ''}
|
||||
${isSelected ? renderTorrentDetails(torrentSummary) : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show name, download status, % complete
|
||||
function renderTorrentMetadata (torrent, torrentSummary) {
|
||||
function renderTorrentMetadata (torrentSummary) {
|
||||
var name = torrentSummary.name || 'Loading torrent...'
|
||||
var elements = [hx`
|
||||
<div class='name ellipsis'>${name}</div>
|
||||
`]
|
||||
|
||||
// If a torrent is paused and we only get the torrentSummary
|
||||
// If it's downloading/seeding then we have more information
|
||||
if (torrent) {
|
||||
var progress = Math.floor(100 * torrent.progress)
|
||||
var downloaded = prettyBytes(torrent.downloaded)
|
||||
var total = prettyBytes(torrent.length || 0)
|
||||
// If it's downloading/seeding then show progress info
|
||||
var prog = torrentSummary.progress
|
||||
if (torrentSummary.state !== 'paused' && prog) {
|
||||
var progress = Math.floor(100 * prog.progress)
|
||||
var downloaded = prettyBytes(prog.downloaded)
|
||||
var total = prettyBytes(prog.length || 0)
|
||||
if (downloaded !== total) downloaded += ` / ${total}`
|
||||
|
||||
elements.push(hx`
|
||||
<div class='status ellipsis'>
|
||||
${getFilesLength()}
|
||||
<span>${getPeers()}</span>
|
||||
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span>
|
||||
<span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
|
||||
<span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
|
||||
</div>
|
||||
`)
|
||||
elements.push(hx`
|
||||
@@ -98,13 +95,13 @@ function TorrentList (state) {
|
||||
return hx`<div class='metadata'>${elements}</div>`
|
||||
|
||||
function getPeers () {
|
||||
var count = torrent.numPeers === 1 ? 'peer' : 'peers'
|
||||
return `${torrent.numPeers} ${count}`
|
||||
var count = prog.numPeers === 1 ? 'peer' : 'peers'
|
||||
return `${prog.numPeers} ${count}`
|
||||
}
|
||||
|
||||
function getFilesLength () {
|
||||
if (torrent.ready && torrent.files.length > 1) {
|
||||
return hx`<span class='files'>${torrent.files.length} files</span>`
|
||||
if (torrentSummary.files && torrentSummary.files.length > 1) {
|
||||
return hx`<span class='files'>${torrentSummary.files.length} files</span>`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,19 +162,19 @@ function TorrentList (state) {
|
||||
}
|
||||
|
||||
// Show files, per-file download status and play buttons, and so on
|
||||
function renderTorrentDetails (torrent, torrentSummary) {
|
||||
function renderTorrentDetails (torrentSummary) {
|
||||
var infoHash = torrentSummary.infoHash
|
||||
var filesElement
|
||||
if (!torrentSummary.files) {
|
||||
// We don't know what files this torrent contains
|
||||
var message = torrent
|
||||
? 'Downloading torrent info...'
|
||||
: 'Failed to load torrent info. Click the download button to try again...'
|
||||
var message = torrentSummary.status === 'paused'
|
||||
? 'Failed to load torrent info. Click the download button to try again...'
|
||||
: 'Downloading torrent info...'
|
||||
filesElement = hx`<div class='files warning'>${message}</div>`
|
||||
} else {
|
||||
// We do know the files. List them and show download stats for each one
|
||||
var fileRows = torrentSummary.files.map(
|
||||
(file, index) => renderFileRow(torrent, torrentSummary, file, index))
|
||||
(file, index) => renderFileRow(torrentSummary, file, index))
|
||||
filesElement = hx`
|
||||
<div class='files'>
|
||||
<strong>Files</strong>
|
||||
@@ -200,10 +197,14 @@ function TorrentList (state) {
|
||||
}
|
||||
|
||||
// Show a single torrentSummary file in the details view for a single torrent
|
||||
function renderFileRow (torrent, torrentSummary, file, index) {
|
||||
function renderFileRow (torrentSummary, file, index) {
|
||||
// First, find out how much of the file we've downloaded
|
||||
var isDone = file.numPiecesPresent === file.numPieces
|
||||
var progress = Math.round(100 * file.numPiecesPresent / (file.numPieces || 0)) + '%'
|
||||
var progress = ''
|
||||
if (torrentSummary.progress) {
|
||||
var fileProg = torrentSummary.progress.files[index]
|
||||
progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
// Second, render the file as a table row
|
||||
var infoHash = torrentSummary.infoHash
|
||||
|
||||
3
renderer/webtorrent.html
Normal file
3
renderer/webtorrent.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<script async src="webtorrent.js"></script>
|
||||
<h1>WebTorrent Hidden Window</h1>
|
||||
298
renderer/webtorrent.js
Normal file
298
renderer/webtorrent.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// To keep the UI snappy, we run WebTorrent in its own hidden window, a separate
|
||||
// process from the main window.
|
||||
console.time('init')
|
||||
|
||||
var WebTorrent = require('webtorrent')
|
||||
var defaultAnnounceList = require('create-torrent').announceList
|
||||
var deepEqual = require('deep-equal')
|
||||
var electron = require('electron')
|
||||
var fs = require('fs')
|
||||
var mkdirp = require('mkdirp')
|
||||
var musicmetadata = require('musicmetadata')
|
||||
var networkAddress = require('network-address')
|
||||
var config = require('../config')
|
||||
var torrentPoster = require('./lib/torrent-poster')
|
||||
var path = require('path')
|
||||
|
||||
// Send & receive messages from the main window
|
||||
var ipc = electron.ipcRenderer
|
||||
|
||||
// Force use of webtorrent trackers on all torrents
|
||||
global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
|
||||
.map((arr) => arr[0])
|
||||
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
|
||||
// client, as explained here: https://webtorrent.io/faq
|
||||
var client = new WebTorrent()
|
||||
|
||||
// WebTorrent-to-HTTP streaming sever
|
||||
var server = null
|
||||
|
||||
// Used for diffing, so we only send progress updates when necessary
|
||||
var prevProgress = null
|
||||
|
||||
init()
|
||||
|
||||
function init () {
|
||||
client.on('warning', (err) => ipc.send('wt-warning', null, err.message))
|
||||
client.on('error', (err) => ipc.send('wt-error', null, err.message))
|
||||
|
||||
ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes) =>
|
||||
startTorrenting(torrentKey, torrentID, path, fileModtimes))
|
||||
ipc.on('wt-stop-torrenting', (e, infoHash) =>
|
||||
stopTorrenting(infoHash))
|
||||
ipc.on('wt-create-torrent', (e, torrentKey, options) =>
|
||||
createTorrent(torrentKey, options))
|
||||
ipc.on('wt-save-torrent-file', (e, torrentKey) =>
|
||||
saveTorrentFile(torrentKey))
|
||||
ipc.on('wt-generate-torrent-poster', (e, torrentKey) =>
|
||||
generateTorrentPoster(torrentKey))
|
||||
ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
|
||||
getAudioMetadata(infoHash, index))
|
||||
ipc.on('wt-start-server', (e, infoHash, index) =>
|
||||
startServer(infoHash, index))
|
||||
ipc.on('wt-stop-server', (e) =>
|
||||
stopServer())
|
||||
|
||||
setInterval(updateTorrentProgress, 1000)
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object
|
||||
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
function startTorrenting (torrentKey, torrentID, path, fileModtimes) {
|
||||
console.log('starting torrent %s: %s', torrentKey, torrentID)
|
||||
var torrent = client.add(torrentID, {
|
||||
path: path,
|
||||
fileModtimes: fileModtimes
|
||||
})
|
||||
torrent.key = torrentKey
|
||||
addTorrentEvents(torrent)
|
||||
return torrent
|
||||
}
|
||||
|
||||
function stopTorrenting (infoHash) {
|
||||
var torrent = client.get(infoHash)
|
||||
torrent.destroy()
|
||||
}
|
||||
|
||||
// Create a new torrent, start seeding
|
||||
function createTorrent (torrentKey, options) {
|
||||
console.log('creating torrent %s', torrentKey, options)
|
||||
var torrent = client.seed(options.files, options)
|
||||
torrent.key = torrentKey
|
||||
addTorrentEvents(torrent)
|
||||
ipc.send('wt-new-torrent')
|
||||
}
|
||||
|
||||
function addTorrentEvents (torrent) {
|
||||
torrent.on('warning', (err) =>
|
||||
ipc.send('wt-warning', torrent.key, err.message))
|
||||
torrent.on('error', (err) =>
|
||||
ipc.send('wt-error', torrent.key, err.message))
|
||||
torrent.on('infoHash', () =>
|
||||
ipc.send('wt-infohash', torrent.key, torrent.infoHash))
|
||||
torrent.on('ready', torrentReady)
|
||||
torrent.on('done', torrentDone)
|
||||
|
||||
function torrentReady () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-ready', torrent.key, info)
|
||||
ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-done', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
|
||||
torrent.getFileModtimes(function (err, fileModtimes) {
|
||||
if (err) return onError(err)
|
||||
ipc.send('wt-file-modtimes', torrent.key, fileModtimes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a torrent
|
||||
function getTorrentInfo (torrent) {
|
||||
return {
|
||||
infoHash: torrent.infoHash,
|
||||
magnetURI: torrent.magnetURI,
|
||||
name: torrent.name,
|
||||
path: torrent.path,
|
||||
files: torrent.files.map(getTorrentFileInfo),
|
||||
bytesReceived: torrent.received
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a file in a torrent
|
||||
function getTorrentFileInfo (file) {
|
||||
return {
|
||||
name: file.name,
|
||||
length: file.length,
|
||||
path: file.path,
|
||||
numPiecesPresent: 0,
|
||||
numPieces: null
|
||||
}
|
||||
}
|
||||
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we never
|
||||
// have to download it again. Never ask the DHT the same question twice.
|
||||
function saveTorrentFile (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) {
|
||||
if (exists) {
|
||||
// We've already saved the file
|
||||
return ipc.send('wt-file-saved', torrentKey, torrentPath)
|
||||
}
|
||||
|
||||
// Otherwise, save the .torrent file, under the app config folder
|
||||
fs.mkdir(config.CONFIG_TORRENT_PATH, function (_) {
|
||||
fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
|
||||
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
|
||||
console.log('saved torrent file %s', torrentPath)
|
||||
return ipc.send('wt-file-saved', torrentKey, torrentPath)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Checks whether we've already resolved a given infohash to a torrent file
|
||||
// Calls back with (torrentPath, exists). Logs, does not call back on error
|
||||
function checkIfTorrentFileExists (infoHash, cb) {
|
||||
var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent')
|
||||
fs.exists(torrentPath, function (exists) {
|
||||
cb(torrentPath, exists)
|
||||
})
|
||||
}
|
||||
|
||||
// Save a JPG that represents a torrent.
|
||||
// Auto chooses either a frame from a video file, an image, etc
|
||||
function generateTorrentPoster (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
torrentPoster(torrent, function (err, buf, extension) {
|
||||
if (err) return console.log('error generating poster: %o', err)
|
||||
// save it for next time
|
||||
mkdirp(config.CONFIG_POSTER_PATH, function (err) {
|
||||
if (err) return console.log('error creating poster dir: %o', err)
|
||||
var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension)
|
||||
fs.writeFile(posterFilePath, buf, function (err) {
|
||||
if (err) return console.log('error saving poster: %o', err)
|
||||
// show the poster
|
||||
ipc.send('wt-poster', torrentKey, posterFilePath)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
var progress = getTorrentProgress()
|
||||
// TODO: diff torrent-by-torrent, not once for the whole update
|
||||
if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) {
|
||||
return /* don't send heavy object if it hasn't changed */
|
||||
}
|
||||
ipc.send('wt-progress', progress)
|
||||
prevProgress = progress
|
||||
}
|
||||
|
||||
function getTorrentProgress () {
|
||||
// First, track overall progress
|
||||
var progress = client.progress
|
||||
var hasActiveTorrents = client.torrents.some(function (torrent) {
|
||||
return torrent.progress !== 1
|
||||
})
|
||||
|
||||
// Track progress for every file in each torrent
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
var torrentProg = client.torrents.map(function (torrent) {
|
||||
var fileProg = torrent.files && torrent.files.map(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
for (var piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
return {
|
||||
startPiece: file._startPiece,
|
||||
endPiece: file._endPiece,
|
||||
numPieces,
|
||||
numPiecesPresent
|
||||
}
|
||||
})
|
||||
return {
|
||||
torrentKey: torrent.key,
|
||||
ready: torrent.ready,
|
||||
progress: torrent.progress,
|
||||
downloaded: torrent.downloaded,
|
||||
downloadSpeed: torrent.downloadSpeed,
|
||||
uploadSpeed: torrent.uploadSpeed,
|
||||
numPeers: torrent.numPeers,
|
||||
length: torrent.length,
|
||||
bitfield: torrent.bitfield,
|
||||
files: fileProg
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
torrents: torrentProg,
|
||||
progress,
|
||||
hasActiveTorrents
|
||||
}
|
||||
}
|
||||
|
||||
function startServer (infoHash, index) {
|
||||
if (server) return
|
||||
var torrent = client.get(infoHash)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent, index)
|
||||
else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index))
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, 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 info = {
|
||||
torrentKey: torrent.key,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix
|
||||
}
|
||||
|
||||
ipc.send('wt-server-running', info)
|
||||
ipc.send('wt-server-' + torrent.infoHash, info)
|
||||
})
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!server) return
|
||||
server.destroy()
|
||||
server = null
|
||||
}
|
||||
|
||||
function getAudioMetadata (infoHash, index) {
|
||||
var torrent = client.get(infoHash)
|
||||
var file = torrent.files[index]
|
||||
musicmetadata(file.createReadStream(), function (err, info) {
|
||||
if (err) return
|
||||
console.log('got audio metadata for %s: %o', file.name, info)
|
||||
ipc.send('wt-audio-metadata', infoHash, index, info)
|
||||
})
|
||||
}
|
||||
|
||||
// Gets a WebTorrent handle by torrentKey
|
||||
// Throws an Error if we're not currently torrenting anything w/ that key
|
||||
function getTorrent (torrentKey) {
|
||||
var ret = client.torrents.find((x) => x.key === torrentKey)
|
||||
if (!ret) throw new Error('missing torrent key ' + torrentKey)
|
||||
return ret
|
||||
}
|
||||
|
||||
function onError (err) {
|
||||
console.log(err)
|
||||
}
|
||||
Reference in New Issue
Block a user