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:
DC
2016-04-04 08:43:27 -07:00
committed by Feross Aboukhadijeh
parent 38ce25592f
commit db9e3e90c5
12 changed files with 726 additions and 427 deletions

View File

@@ -65,6 +65,7 @@ module.exports = {
}, },
WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'), WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'),
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'),
WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html') WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html')
} }

View File

@@ -54,6 +54,7 @@ function init () {
app.on('ready', function () { app.on('ready', function () {
menu.init() menu.init()
windows.createMainWindow() windows.createMainWindow()
windows.createWebTorrentHiddenWindow()
shortcuts.init() shortcuts.init()
tray.init() tray.init()
handlers.install() handlers.install()

View File

@@ -69,6 +69,28 @@ function init () {
menu.onPlayerClose() menu.onPlayerClose()
shortcuts.unregisterPlayerShortcuts() shortcuts.unregisterPlayerShortcuts()
}) })
// Capture all events
var oldEmit = ipcMain.emit
ipcMain.emit = function (name, e, ...args) {
// Relay messages between the main window and the WebTorrent hidden window
if (name.startsWith('wt-')) {
var recipient, recipientStr
if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
recipient = windows.main
recipientStr = 'main'
} else {
recipient = windows.webtorrent
recipientStr = 'webtorrent'
}
console.log('sending %s to %s', name, recipientStr)
recipient.send(name, ...args)
return
}
// Emit all other events normally
oldEmit.call(ipcMain, name, e, ...args)
}
} }
function setBounds (bounds, maximize) { function setBounds (bounds, maximize) {

View File

@@ -124,7 +124,10 @@ function showCreateTorrent () {
properties: [ 'openFile', 'openDirectory' ] properties: [ 'openFile', 'openDirectory' ]
}, function (filenames) { }, function (filenames) {
if (!Array.isArray(filenames)) return if (!Array.isArray(filenames)) return
windows.main.send('dispatch', 'seed', filenames[0]) var options = {
files: filenames[0]
}
windows.main.send('dispatch', 'createTorrent', options)
}) })
} }

View File

@@ -1,9 +1,10 @@
var windows = module.exports = { var windows = module.exports = {
about: null, about: null,
main: null, main: null,
createAboutWindow: createAboutWindow, createAboutWindow,
createMainWindow: createMainWindow, createWebTorrentHiddenWindow,
focusWindow: focusWindow createMainWindow,
focusWindow
} }
var electron = require('electron') var electron = require('electron')
@@ -46,6 +47,25 @@ function createAboutWindow () {
}) })
} }
function createWebTorrentHiddenWindow () {
var win = windows.webtorrent = new electron.BrowserWindow({
backgroundColor: '#ECECEC',
show: false,
center: true,
title: 'webtorrent-hidden-window',
width: 500,
height: 500,
minimizable: false,
maximizable: false,
fullscreen: false,
skipTaskbar: true
})
win.loadURL(config.WINDOW_WEBTORRENT)
// To debug the WebTorrent process, set `show` to true above and uncomment:
// win.webContents.openDevTools({detached: false})
}
function createMainWindow () { function createMainWindow () {
if (windows.main) { if (windows.main) {
return focusWindow(windows.main) return focusWindow(windows.main)
@@ -56,7 +76,7 @@ function createMainWindow () {
icon: config.APP_ICON + '.png', icon: config.APP_ICON + '.png',
minWidth: 375, minWidth: 375,
minHeight: 38 + (120 * 2), // header height + 2 torrents minHeight: 38 + (120 * 2), // header height + 2 torrents
show: false, // Hide window until DOM finishes loading show: true, // Hide window until DOM finishes loading
title: config.APP_WINDOW_TITLE, title: config.APP_WINDOW_TITLE,
titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X) titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X)
useContentSize: true, // Specify web page size without OS chrome useContentSize: true, // Specify web page size without OS chrome

View File

@@ -17,8 +17,10 @@
"airplay-js": "guerrerocarlos/node-airplay-js", "airplay-js": "guerrerocarlos/node-airplay-js",
"application-config": "^0.2.0", "application-config": "^0.2.0",
"application-config-path": "^0.1.0", "application-config-path": "^0.1.0",
"bitfield": "^1.0.2",
"chromecasts": "^1.8.0", "chromecasts": "^1.8.0",
"create-torrent": "^3.22.1", "create-torrent": "^3.22.1",
"deep-equal": "^1.0.1",
"dlnacasts": "^0.0.2", "dlnacasts": "^0.0.2",
"drag-drop": "^2.11.0", "drag-drop": "^2.11.0",
"electron-localshortcut": "^0.6.0", "electron-localshortcut": "^0.6.0",

View File

@@ -1,15 +1,11 @@
console.time('init') console.time('init')
var cfg = require('application-config')('WebTorrent') var cfg = require('application-config')('WebTorrent')
var defaultAnnounceList = require('create-torrent').announceList
var dragDrop = require('drag-drop') var dragDrop = require('drag-drop')
var electron = require('electron') var electron = require('electron')
var EventEmitter = require('events') var EventEmitter = require('events')
var fs = require('fs') var fs = require('fs')
var mainLoop = require('main-loop') var mainLoop = require('main-loop')
var mkdirp = require('mkdirp')
var musicmetadata = require('musicmetadata')
var networkAddress = require('network-address')
var path = require('path') var path = require('path')
var remote = require('remote') var remote = require('remote')
@@ -21,14 +17,11 @@ var App = require('./views/app')
var errors = require('./lib/errors') var errors = require('./lib/errors')
var config = require('../config') var config = require('../config')
var TorrentPlayer = require('./lib/torrent-player') var TorrentPlayer = require('./lib/torrent-player')
var torrentPoster = require('./lib/torrent-poster')
var util = require('./util') var util = require('./util')
var {setDispatch} = require('./lib/dispatcher') var {setDispatch} = require('./lib/dispatcher')
setDispatch(dispatch) setDispatch(dispatch)
// These two dependencies are the slowest-loading, so we lazy load them // This dependency is the slowest-loading, so we lazy load it
// This cuts time from icon click to rendered window from ~550ms to ~150ms on my laptop
var WebTorrent = null
var Cast = null var Cast = null
// Electron apps have two processes: a main process (node) runs first and starts // 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 // For easy debugging in Developer Tools
var state = global.state = require('./state') 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 var vdomLoop
// All state lives in state.js. `state.saved` is read from and written to a file. // 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' }) state.location.go({ url: 'home' })
// Lazily load the WebTorrent, Chromecast, and Airplay modules // Lazily load the WebTorrent, Chromecast, and Airplay modules
window.setTimeout(function () { initWebtorrent()
lazyLoadClient() window.setTimeout(lazyLoadCast, 750)
lazyLoadCast()
}, 750)
// The UI is built with virtual-dom, a minimalist library extracted from React // 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 // The concepts--one way data flow, a pure function that renders state to a
@@ -125,12 +111,6 @@ function init () {
console.timeEnd('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 // Lazily loads Chromecast and Airplay support
function lazyLoadCast () { function lazyLoadCast () {
if (!Cast) { if (!Cast) {
@@ -140,41 +120,25 @@ function lazyLoadCast () {
return Cast return Cast
} }
// Load the WebTorrent module, connect to both the WebTorrent and BitTorrent // Talk to WebTorrent process, resume torrents, start monitoring torrent progress
// networks, resume torrents, start monitoring torrent progress
function initWebtorrent () { 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 // Restart everything we were torrenting last time the app ran
resumeTorrents() resumeTorrents()
// Calling update() updates the UI given the current state // Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary // 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 // a progress bar and to keep the cursor in sync when playing a video
setInterval(function () { setInterval(update, 1000)
if (!updateTorrentProgress()) {
update() // If we didn't just update(), do so now, for the video cursor
}
}, 1000)
} }
// This is the (mostly) pure function from state -> UI. Returns a virtual DOM // 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() // tree. Any events, such as button clicks, will turn into calls to dispatch()
function render (state) { 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. // 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') { if (action === 'showOpenTorrentFile') {
ipcRenderer.send('showOpenTorrentFile') /* open torrent file */ ipcRenderer.send('showOpenTorrentFile') /* open torrent file */
} }
if (action === 'seed') { if (action === 'createTorrent') {
// TODO: right now, creating a torrent thru File > Create New Torrent createTorrent(args[0] /* options */)
// 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 === 'openFile') { if (action === 'openFile') {
openFile(args[0] /* infoHash */, args[1] /* index */) openFile(args[0] /* infoHash */, args[1] /* index */)
@@ -321,14 +277,11 @@ function dispatch (action, ...args) {
if (action === 'skipVersion') { if (action === 'skipVersion') {
if (!state.saved.skippedVersions) state.saved.skippedVersions = [] if (!state.saved.skippedVersions) state.saved.skippedVersions = []
state.saved.skippedVersions.push(args[0] /* version */) state.saved.skippedVersions.push(args[0] /* version */)
saveState() saveStateThrottled()
} }
if (action === 'saveState') { if (action === 'saveState') {
saveState() saveState()
} }
if (action === 'createTorrent') {
createTorrent(args[0] /* options */)
}
// Update the virtual-dom, unless it's just a mouse move event // Update the virtual-dom, unless it's just a mouse move event
if (action !== 'mediaMouseMoved') { if (action !== 'mediaMouseMoved') {
@@ -404,6 +357,19 @@ function setupIpc () {
state.devices[device] = player state.devices[device] = player
update() 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 // Load state.saved from the JSON state file
@@ -429,24 +395,46 @@ function resumeTorrents () {
.forEach((x) => startTorrentingSummary(x)) .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 // Write state.saved to the JSON state file
function saveState () { function saveState () {
console.log('saving state to ' + cfg.filePath) console.log('saving state to ' + cfg.filePath)
// Clean up, so that we're not saving any pending state // 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 // 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 // reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents.filter((x) => x.infoHash) copy.torrents = copy.torrents
copy.torrents.forEach(function (x) { .filter((x) => x.infoHash)
if (x.playStatus !== 'unplayable') delete x.playStatus .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) { cfg.write(copy, function (err) {
if (err) console.error(err) if (err) console.error(err)
ipcRenderer.send('savedState') ipcRenderer.send('savedState')
update()
}) })
// Update right away, don't wait for the state to save
update()
} }
function onOpen (files) { function onOpen (files) {
@@ -454,11 +442,11 @@ function onOpen (files) {
// .torrent file = start downloading the torrent // .torrent file = start downloading the torrent
files.filter(isTorrent).forEach(function (torrentFile) { files.filter(isTorrent).forEach(function (torrentFile) {
addTorrent(torrentFile) addTorrent(torrentFile.path)
}) })
// everything else = seed these files // everything else = seed these files
showCreateTorrentModal(files.filter(isNotTorrent)) createTorrentFromFileObjects(files)
} }
function onPaste (e) { function onPaste (e) {
@@ -485,316 +473,233 @@ function isNotTorrent (file) {
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents // Gets a torrent summary {name, infoHash, status} from state.saved.torrents
// Returns undefined if we don't know that infoHash // Returns undefined if we don't know that infoHash
function getTorrentSummary (infoHash) { function getTorrentSummary (torrentKey) {
if (!infoHash) return undefined if (!torrentKey) return undefined
return state.saved.torrents.find((x) => x.infoHash === infoHash) return state.saved.torrents.find((x) =>
} x.torrentKey === torrentKey || x.infoHash === torrentKey)
// 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)
} }
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a // 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- // magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
function addTorrent (torrentId) { function addTorrent (torrentId) {
var torrent = startTorrentingID(torrentId) var torrentKey = state.nextTorrentKey++
torrent.on('infoHash', function () { var path = state.saved.downloadPath
addTorrentToList(torrent) ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
})
}
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()
}
} }
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object // Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
function startTorrentingSummary (torrentSummary) { function startTorrentingSummary (torrentSummary) {
var s = torrentSummary var s = torrentSummary
if (s.torrentPath) {
var torrentPath = util.getAbsoluteStaticPath(s.torrentPath) // Backward compatibility for config files save before we had torrentKey
var ret = startTorrentingID(torrentPath, s.path, s.fileModtimes) if (!s.torrentKey) s.torrentKey = state.nextTorrentKey++
if (s.infoHash) state.pendingTorrents[s.infoHash] = ret
return ret // Use Downloads folder by default
} else if (s.magnetURI) { var path = s.path || state.saved.downloadPath
return startTorrentingID(s.magnetURI, s.path, s.fileModtimes)
} else { var torrentID
return startTorrentingID(s.infoHash, s.path, s.fileModtimes) 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 // TODO: maybe have a "create torrent" modal in the future, with options like
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent- // custom trackers, private flag, and so on?
function startTorrentingID (torrentID, path, fileModtimes) { //
console.log('starting torrent ' + torrentID) // Right now create-torrent-modal is v basic, only user input is OK / Cancel
var torrent = lazyLoadClient().add(torrentID, { //
path: path || state.saved.downloadPath, // Use downloads folder // Also, if you uncomment below below, creating a torrent thru
fileModtimes: fileModtimes // File > Create New Torrent will still create a new torrent directly, while
}) // dragging files or folders onto the app opens the create-torrent-modal
addTorrentEvents(torrent) //
return torrent // 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) { // TORRENT MANAGEMENT
var torrent = getTorrent(infoHash) // Send commands to the WebTorrent process, handle events
if (torrent) torrent.destroy() //
}
// Prompts the user to create a torrent for a local file or folder // Creates a new torrent from a drag-dropped file or folder
function showCreateTorrentModal (files) { function createTorrentFromFileObjects (files) {
if (files.length === 0) return var filePaths = (files
state.modal = { .filter(isNotTorrent)
id: 'create-torrent-modal', .map((x) => x.path))
files: files
// 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 // Creates a new torrent and start seeeding
function createTorrent (options) { function createTorrent (options) {
var torrent = lazyLoadClient().seed(options.files, options) var torrentKey = state.nextTorrentKey++
addTorrentToList(torrent) ipcRenderer.send('wt-create-torrent', torrentKey, options)
addTorrentEvents(torrent)
} }
function addTorrentEvents (torrent) { function torrentInfoHash (torrentKey, infoHash) {
torrent.on('infoHash', function () { var torrentSummary = getTorrentSummary(torrentKey)
var infoHash = torrent.infoHash console.log('got infohash for %s torrent %s',
if (state.pendingTorrents[infoHash]) delete state.pendingTorrents[infoHash] torrentSummary ? 'existing' : 'new', torrentKey)
update()
})
torrent.on('ready', torrentReady)
torrent.on('done', torrentDone)
torrent.on('warning', onWarning)
torrent.on('error', torrentError)
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', console.log('error, stopping torrent %s (%s):\n\t%o',
torrent.name, torrent.infoHash, err.message) torrentSummary.name, torrentSummary.infoHash, message)
// TODO: update torrentSummary, even if it doesn't have an infohash yet torrentSummary.status = 'paused'
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)
}
update() update()
} }
} }
function updateTorrentProgress () { function torrentReady (torrentKey, torrentInfo) {
var changed = false // 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% // 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 progress = -1
} }
// Show progress bar under the WebTorrent taskbar icon, on OSX // Show progress bar under the WebTorrent taskbar icon, on OSX
if (state.dock.progress !== progress) changed = true
state.dock.progress = progress state.dock.progress = progress
// Track progress for every file in each torrentSummary // Update progress for each individual torrent
// TODO: ideally this would be tracked by WebTorrent, which could do it progressInfo.torrents.forEach(function (p) {
// more efficiently than looping over torrent.bitfield var torrentSummary = getTorrentSummary(p.torrentKey)
lazyLoadClient().torrents.forEach(function (torrent) { if (!torrentSummary) {
var torrentSummary = getTorrentSummary(torrent.infoHash) console.log('warning: got progress for missing torrent %s', p.torrentKey)
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()
return return
} }
torrentSummary.progress = p
// 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()
})
})
}) })
update()
} }
// Checks whether we've already resolved a given infohash to a torrent file function torrentFileModtimes (torrentKey, fileModtimes) {
// Calls back with (torrentPath, exists). Logs, does not call back on error var torrentSummary = getTorrentSummary(torrentKey)
function checkIfTorrentFileExists (infoHash, cb) { torrentSummary.fileModtimes = fileModtimes
var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent') saveStateThrottled()
fs.exists(torrentPath, function (exists) {
cb(torrentPath, exists)
})
} }
function startServer (torrentSummary, index, cb) { function torrentFileSaved (torrentKey, torrentPath) {
if (state.server) return cb() console.log('torrent file saved %s: %s', torrentKey, torrentPath)
var torrentSummary = getTorrentSummary(torrentKey)
var torrent = getTorrent(torrentSummary.infoHash) torrentSummary.torrentPath = torrentPath
if (!torrent) torrent = startTorrentingSummary(torrentSummary) saveStateThrottled()
if (torrent.ready) startServerFromReadyTorrent(torrent, index, cb)
else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index, cb))
} }
function startServerFromReadyTorrent (torrent, index, cb) { function torrentPosterSaved (torrentKey, posterPath) {
// automatically choose which file in the torrent to play, if necessary var torrentSummary = getTorrentSummary(torrentKey)
if (index === undefined) index = pickFileToPlay(torrent.files) torrentSummary.posterURL = posterPath
if (index === undefined) return cb(new errors.UnplayableError()) saveStateThrottled()
var file = torrent.files[index] }
// update state function torrentAudioMetadata (infoHash, index, info) {
state.playing.infoHash = torrent.infoHash var torrentSummary = getTorrentSummary(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)
var fileSummary = torrentSummary.files[index] var fileSummary = torrentSummary.files[index]
if (state.playing.type === 'audio' && !fileSummary.audioInfo) { fileSummary.audioInfo = info
musicmetadata(file.createReadStream(), function (err, info) { update()
if (err) return }
console.log('got audio metadata for %s: %o', file.name, info)
fileSummary.audioInfo = info
update()
})
}
// either way, start a streaming torrent-to-http server function torrentServerRunning (serverInfo) {
var server = torrent.createServer() state.server = serverInfo
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()
})
} }
// Picks the default file to play from a list of torrent or torrentSummary files // Picks the default file to play from a list of torrent or torrentSummary files
@@ -820,18 +725,22 @@ function pickFileToPlay (files) {
} }
function stopServer () { function stopServer () {
if (!state.server) return ipcRenderer.send('wt-stop-server')
state.server.server.destroy()
state.server = null
state.playing.infoHash = null state.playing.infoHash = null
state.playing.fileIndex = null state.playing.fileIndex = null
state.server = null
} }
// Opens the video player // Opens the video player
function openPlayer (infoHash, index, cb) { function openPlayer (infoHash, index, cb) {
var torrentSummary = getTorrentSummary(infoHash) 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' torrentSummary.playStatus = 'requested'
update() update()
@@ -842,14 +751,43 @@ function openPlayer (infoHash, index, cb) {
update() update()
}, 10000) /* give it a few seconds */ }, 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) clearTimeout(timeout)
/**
TODO: where do we know if it's unplayable?
if (err) { if (err) {
torrentSummary.playStatus = 'unplayable' torrentSummary.playStatus = 'unplayable'
playInterfaceSound('ERROR') playInterfaceSound('ERROR')
update() update()
return onError(err) return onError(err)
} }
*/
// if we timed out (user clicked play a long time ago), don't autoplay // if we timed out (user clicked play a long time ago), don't autoplay
var timedOut = torrentSummary.playStatus === 'timeout' var timedOut = torrentSummary.playStatus === 'timeout'
@@ -884,28 +822,25 @@ function closePlayer (cb) {
} }
function openFile (infoHash, index) { function openFile (infoHash, index) {
var torrent = lazyLoadClient().get(infoHash) var torrentSummary = getTorrentSummary(infoHash)
if (!torrent) return var filePath = path.join(
torrentSummary.path,
var filePath = path.join(torrent.path, torrent.files[index].path) torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath) ipcRenderer.send('openItem', filePath)
} }
function openFolder (infoHash) { function openFolder (infoHash) {
var torrent = lazyLoadClient().get(infoHash) var torrentSummary = getTorrentSummary(infoHash)
if (!torrent) return
var folderPath = path.join(torrent.path, torrent.name) var firstFilePath = path.join(
// Multi-file torrents create their own folder, single file torrents just torrentSummary.path,
// drop the file directly into the Downloads folder torrentSummary.files[0].path)
fs.stat(folderPath, function (err, stats) { var folderPath = path.dirname(firstFilePath)
if (err || !stats.isDirectory()) {
folderPath = torrent.path ipcRenderer.send('openItem', folderPath)
}
ipcRenderer.send('openItem', folderPath)
})
} }
// TODO: use torrentKey, not infoHash
function toggleTorrent (infoHash) { function toggleTorrent (infoHash) {
var torrentSummary = getTorrentSummary(infoHash) var torrentSummary = getTorrentSummary(infoHash)
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
@@ -914,18 +849,18 @@ function toggleTorrent (infoHash) {
playInterfaceSound('ENABLE') playInterfaceSound('ENABLE')
} else { } else {
torrentSummary.status = 'paused' torrentSummary.status = 'paused'
stopTorrenting(torrentSummary.infoHash) ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
playInterfaceSound('DISABLE') playInterfaceSound('DISABLE')
} }
} }
// TODO: use torrentKey, not infoHash
function deleteTorrent (infoHash) { function deleteTorrent (infoHash) {
var torrent = getTorrent(infoHash) ipcRenderer.send('wt-stop-torrenting', infoHash)
if (torrent) torrent.destroy()
var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash) var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) state.saved.torrents.splice(index, 1) if (index > -1) state.saved.torrents.splice(index, 1)
saveState() saveStateThrottled()
state.location.clearForward() // prevent user from going forward to a deleted torrent state.location.clearForward() // prevent user from going forward to a deleted torrent
playInterfaceSound('DELETE') playInterfaceSound('DELETE')
} }
@@ -1032,7 +967,7 @@ function onError (err) {
} }
function onWarning (err) { function onWarning (err) {
console.log('warning: %s', err.message) console.log('warning: %s', err.message || err)
} }
function showDoneNotification (torrent) { function showDoneNotification (torrent) {
@@ -1067,3 +1002,13 @@ function setupCrashReporter () {
submitURL: config.CRASH_REPORT_URL 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)
}

View File

@@ -32,7 +32,6 @@ module.exports = {
lastTimeUpdate: 0, /* Unix time in ms */ lastTimeUpdate: 0, /* Unix time in ms */
mouseStationarySince: 0 /* Unix time in ms */ mouseStationarySince: 0 /* Unix time in ms */
}, },
pendingTorrents: {}, /* infohash to WebTorrent handle */
devices: { /* playback devices like Chromecast and AppleTV */ devices: { /* playback devices like Chromecast and AppleTV */
airplay: null, /* airplay client. finds and manages AppleTVs */ airplay: null, /* airplay client. finds and manages AppleTVs */
chromecast: null /* chromecast client. finds and manages Chromecasts */ chromecast: null /* chromecast client. finds and manages Chromecasts */
@@ -43,6 +42,7 @@ module.exports = {
}, },
modal: null, /* modal popover */ modal: null, /* modal popover */
errors: [], /* user-facing errors */ 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. * Saved state is read from and written to a file every time the app runs.

View File

@@ -5,6 +5,7 @@ var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
var prettyBytes = require('prettier-bytes') var prettyBytes = require('prettier-bytes')
var Bitfield = require('bitfield')
var util = require('../util') var util = require('../util')
var {dispatch, dispatcher} = require('../lib/dispatcher') var {dispatch, dispatcher} = require('../lib/dispatcher')
@@ -20,7 +21,7 @@ function Player (state) {
onmousemove=${dispatcher('mediaMouseMoved')}> onmousemove=${dispatcher('mediaMouseMoved')}>
${showVideo ? renderMedia(state) : renderCastScreen(state)} ${showVideo ? renderMedia(state) : renderCastScreen(state)}
${renderPlayerControls(state)} ${renderPlayerControls(state)}
</div> </div>
` `
} }
@@ -161,18 +162,20 @@ function renderLoadingSpinner (state) {
(new Date().getTime() - state.playing.lastTimeUpdate > 2000) (new Date().getTime() - state.playing.lastTimeUpdate > 2000)
if (!isProbablyStalled) return if (!isProbablyStalled) return
var torrentSummary = getPlayingTorrentSummary(state) var prog = getPlayingTorrentSummary(state).progress || {}
var torrent = state.client.get(torrentSummary.infoHash) var fileProgress = 0
var file = torrentSummary.files[state.playing.fileIndex] if (prog.files) {
var progress = Math.floor(100 * file.numPiecesPresent / file.numPieces) var file = prog.files[state.playing.fileIndex]
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
}
return hx` return hx`
<div class='media-stalled'> <div class='media-stalled'>
<div class='loading-spinner'>&nbsp;</div> <div class='loading-spinner'>&nbsp;</div>
<div class='loading-status ellipsis'> <div class='loading-status ellipsis'>
<span class='progress'>${progress}%</span> downloaded, <span class='progress'>${fileProgress}%</span> downloaded,
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span> <span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span> <span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div> </div>
</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) { function renderPlayerControls (state) {
var positionPercent = 100 * state.playing.currentTime / state.playing.duration var positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } 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 // Renders the loading bar. Shows which parts of the torrent are loaded, which
// can be "spongey" / non-contiguous // can be "spongey" / non-contiguous
function renderLoadingBar (state) { function renderLoadingBar (state) {
var torrent = state.client.get(state.playing.infoHash) var torrentSummary = getPlayingTorrentSummary(state)
if (torrent === null) { if (!torrentSummary.progress) {
return [] return []
} }
var file = torrent.files[state.playing.fileIndex]
// Find all contiguous parts of the torrent which are loaded // Find all contiguous parts of the torrent which are loaded
var prog = torrentSummary.progress
var fileProg = prog.files[state.playing.fileIndex]
var parts = [] var parts = []
var lastPartPresent = false var lastPiecePresent = false
var numParts = file._endPiece - file._startPiece + 1 for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
for (var i = file._startPiece; i <= file._endPiece; i++) { var partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
var partPresent = torrent.bitfield.get(i) if (partPresent && !lastPiecePresent) {
if (partPresent && !lastPartPresent) { parts.push({start: i - fileProg.startPiece, count: 1})
parts.push({start: i - file._startPiece, count: 1})
} else if (partPresent) { } else if (partPresent) {
parts[parts.length - 1].count++ parts[parts.length - 1].count++
} }
lastPartPresent = partPresent lastPiecePresent = partPresent
} }
// Output some bars to show which parts of the file are loaded // Output some bars to show which parts of the file are loaded
@@ -365,8 +349,8 @@ function renderLoadingBar (state) {
<div class='loading-bar'> <div class='loading-bar'>
${parts.map(function (part) { ${parts.map(function (part) {
var style = { var style = {
left: (100 * part.start / numParts) + '%', left: (100 * part.start / fileProg.numPieces) + '%',
width: (100 * part.count / numParts) + '%' width: (100 * part.count / fileProg.numPieces) + '%'
} }
return hx`<div class='loading-bar-part' style=${style}></div>` return hx`<div class='loading-bar-part' style=${style}></div>`
@@ -374,3 +358,22 @@ function renderLoadingBar (state) {
</div> </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)
}

View File

@@ -27,9 +27,6 @@ function TorrentList (state) {
function renderTorrent (torrentSummary) { function renderTorrent (torrentSummary) {
// Get ephemeral data (like progress %) directly from the WebTorrent handle // Get ephemeral data (like progress %) directly from the WebTorrent handle
var infoHash = torrentSummary.infoHash var infoHash = torrentSummary.infoHash
var torrent = state.client
? state.client.torrents.find((x) => x.infoHash === infoHash)
: null
var isSelected = infoHash && state.selectedInfoHash === infoHash var isSelected = infoHash && state.selectedInfoHash === infoHash
// Background image: show some nice visuals, like a frame from the movie, if possible // 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} <div style=${style} class=${classes}
oncontextmenu=${infoHash && dispatcher('openTorrentContextMenu', infoHash)} oncontextmenu=${infoHash && dispatcher('openTorrentContextMenu', infoHash)}
onclick=${infoHash && dispatcher('toggleSelectTorrent', infoHash)}> onclick=${infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
${renderTorrentMetadata(torrent, torrentSummary)} ${renderTorrentMetadata(torrentSummary)}
${infoHash ? renderTorrentButtons(torrentSummary) : ''} ${infoHash ? renderTorrentButtons(torrentSummary) : ''}
${isSelected ? renderTorrentDetails(torrent, torrentSummary) : ''} ${isSelected ? renderTorrentDetails(torrentSummary) : ''}
</div> </div>
` `
} }
// Show name, download status, % complete // Show name, download status, % complete
function renderTorrentMetadata (torrent, torrentSummary) { function renderTorrentMetadata (torrentSummary) {
var name = torrentSummary.name || 'Loading torrent...' var name = torrentSummary.name || 'Loading torrent...'
var elements = [hx` var elements = [hx`
<div class='name ellipsis'>${name}</div> <div class='name ellipsis'>${name}</div>
`] `]
// If a torrent is paused and we only get the torrentSummary // If it's downloading/seeding then show progress info
// If it's downloading/seeding then we have more information var prog = torrentSummary.progress
if (torrent) { if (torrentSummary.state !== 'paused' && prog) {
var progress = Math.floor(100 * torrent.progress) var progress = Math.floor(100 * prog.progress)
var downloaded = prettyBytes(torrent.downloaded) var downloaded = prettyBytes(prog.downloaded)
var total = prettyBytes(torrent.length || 0) var total = prettyBytes(prog.length || 0)
if (downloaded !== total) downloaded += ` / ${total}` if (downloaded !== total) downloaded += ` / ${total}`
elements.push(hx` elements.push(hx`
<div class='status ellipsis'> <div class='status ellipsis'>
${getFilesLength()} ${getFilesLength()}
<span>${getPeers()}</span> <span>${getPeers()}</span>
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span> <span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span> <span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div> </div>
`) `)
elements.push(hx` elements.push(hx`
@@ -98,13 +95,13 @@ function TorrentList (state) {
return hx`<div class='metadata'>${elements}</div>` return hx`<div class='metadata'>${elements}</div>`
function getPeers () { function getPeers () {
var count = torrent.numPeers === 1 ? 'peer' : 'peers' var count = prog.numPeers === 1 ? 'peer' : 'peers'
return `${torrent.numPeers} ${count}` return `${prog.numPeers} ${count}`
} }
function getFilesLength () { function getFilesLength () {
if (torrent.ready && torrent.files.length > 1) { if (torrentSummary.files && torrentSummary.files.length > 1) {
return hx`<span class='files'>${torrent.files.length} files</span>` 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 // Show files, per-file download status and play buttons, and so on
function renderTorrentDetails (torrent, torrentSummary) { function renderTorrentDetails (torrentSummary) {
var infoHash = torrentSummary.infoHash var infoHash = torrentSummary.infoHash
var filesElement var filesElement
if (!torrentSummary.files) { if (!torrentSummary.files) {
// We don't know what files this torrent contains // We don't know what files this torrent contains
var message = torrent var message = torrentSummary.status === 'paused'
? 'Downloading torrent info...' ? 'Failed to load torrent info. Click the download button to try again...'
: 'Failed to load torrent info. Click the download button to try again...' : 'Downloading torrent info...'
filesElement = hx`<div class='files warning'>${message}</div>` filesElement = hx`<div class='files warning'>${message}</div>`
} else { } else {
// We do know the files. List them and show download stats for each one // We do know the files. List them and show download stats for each one
var fileRows = torrentSummary.files.map( var fileRows = torrentSummary.files.map(
(file, index) => renderFileRow(torrent, torrentSummary, file, index)) (file, index) => renderFileRow(torrentSummary, file, index))
filesElement = hx` filesElement = hx`
<div class='files'> <div class='files'>
<strong>Files</strong> <strong>Files</strong>
@@ -200,10 +197,14 @@ function TorrentList (state) {
} }
// Show a single torrentSummary file in the details view for a single torrent // 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 // First, find out how much of the file we've downloaded
var isDone = file.numPiecesPresent === file.numPieces 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 // Second, render the file as a table row
var infoHash = torrentSummary.infoHash var infoHash = torrentSummary.infoHash

3
renderer/webtorrent.html Normal file
View File

@@ -0,0 +1,3 @@
<!doctype html>
<script async src="webtorrent.js"></script>
<h1>WebTorrent Hidden Window</h1>

298
renderer/webtorrent.js Normal file
View 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)
}