diff --git a/main/ipc.js b/main/ipc.js index 02d99fbc..9fdaf938 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -64,7 +64,7 @@ function setAspectRatio (aspectRatio, extraSize) { // Display string in dock badging area (OS X) function setBadge (text) { debug('setBadge %s', text) - electron.app.dock.setBadge(String(text)) + if (electron.app.dock) electron.app.dock.setBadge(String(text)) } // Show progress bar. Valid range is [0, 1]. Remove when < 0; indeterminate when > 1. diff --git a/main/windows.js b/main/windows.js index 0a02e445..1ab7c3b0 100644 --- a/main/windows.js +++ b/main/windows.js @@ -25,7 +25,7 @@ function createMainWindow (menu) { title: config.APP_NAME, titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X) width: 450, - height: 300 + height: 450 }) win.loadURL(config.INDEX) diff --git a/package.json b/package.json index 447e8397..3281076f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "airplay-js": "guerrerocarlos/node-airplay-js", "application-config": "^0.2.0", + "application-config-path": "^0.1.0", "chromecasts": "^1.8.0", "create-torrent": "^3.22.1", "debug": "^2.2.0", @@ -24,8 +25,7 @@ "pretty-bytes": "^3.0.0", "upload-element": "^1.0.1", "virtual-dom": "^2.1.1", - "webtorrent": "^0.82.1", - "xtend": "^4.0.1" + "webtorrent": "^0.82.1" }, "devDependencies": { "electron-packager": "^5.0.0", diff --git a/renderer/index.css b/renderer/index.css index e15ba144..c023a7a4 100644 --- a/renderer/index.css +++ b/renderer/index.css @@ -288,6 +288,7 @@ body.drag::before { position: absolute; top: 20px; left: 20px; + width: calc(100% - 100px); text-shadow: rgba(0, 0, 0, 0.5) 0 0 4px; } @@ -317,6 +318,22 @@ body.drag::before { padding-top: 6px; } +.torrent .buttons .download.downloading { + animation-name: greenpulse; + animation-duration: 0.8s; + animation-direction: alternate; + animation-iteration-count: infinite; +} + +@keyframes greenpulse { + 0% { color: #ffffff } + 100% { color: #44dd44 } +} + +.torrent .buttons .download.seeding { + color: #44dd44; +} + .torrent .buttons .play { padding-top: 10px; background-color: #F44336; diff --git a/renderer/index.js b/renderer/index.js index bd2f9732..ee0acd96 100644 --- a/renderer/index.js +++ b/renderer/index.js @@ -1,18 +1,16 @@ -/* global URL, Blob */ - console.time('init') var airplay = require('airplay-js') var cfg = require('application-config')('WebTorrent') +var cfgDirectory = require('application-config-path')('WebTorrent') var chromecasts = require('chromecasts')() var config = require('../config') var createTorrent = require('create-torrent') var dragDrop = require('drag-drop') var electron = require('electron') var EventEmitter = require('events') -var extend = require('xtend') +var fs = require('fs') var mainLoop = require('main-loop') var networkAddress = require('network-address') -var os = require('os') var path = require('path') var torrentPoster = require('./lib/torrent-poster') var WebTorrent = require('webtorrent') @@ -23,8 +21,6 @@ var patch = require('virtual-dom/patch') var App = require('./views/app') -var HOME = os.homedir() - // For easy debugging in Developer Tools var state = global.state = require('./state') @@ -38,10 +34,6 @@ global.WEBTORRENT_ANNOUNCE = createTorrent.announceList }) var vdomLoop -var defaultSaved = { - torrents: [], - downloadPath: path.join(HOME, 'Downloads') -} // All state lives in state.js. `state.saved` is read from and written to a file. // All other state is ephemeral. First we load state.saved then initialize the app. @@ -53,14 +45,12 @@ loadState(init) * the dock icon and drag+drop. */ function init () { - document.querySelector('.loading').remove() - // Connect to the WebTorrent and BitTorrent networks // WebTorrent.app is a hybrid client, as explained here: https://webtorrent.io/faq state.client = new WebTorrent() state.client.on('warning', onWarning) state.client.on('error', onError) - state.client.on('torrent', saveTorrentData) + resumeTorrents() /* restart everything we were torrenting last time the app ran */ // 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 @@ -78,8 +68,8 @@ function init () { // (eg % downloaded) and to keep the cursor in sync when playing a video setInterval(update, 1000) - // Resume all saved torrents now that state is loaded and vdom is ready - resumeAllTorrents() + // All state lives in state.js. `state.saved` is read from and written to a + // file. All other state is ephemeral. Here we'll load state.saved: window.addEventListener('beforeunload', saveState) // listen for messages from the main process @@ -121,6 +111,8 @@ function init () { state.isFocused = false }) + // Done! Ideally we want to get here <100ms after the user clicks the app + document.querySelector('.loading').remove() /* TODO: no spinner once fast enough */ console.timeEnd('init') } @@ -138,24 +130,29 @@ function update () { // Events from the UI never modify state directly. Instead they call dispatch() function dispatch (action, ...args) { - console.log('dispatch: %s %o', action, args) + if (['videoMouseMoved', 'playbackJump'].indexOf(action) < 0) { + console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */ + } if (action === 'addTorrent') { - addTorrent(args[0] /* torrentId */) + addTorrent(args[0] /* torrent */) } if (action === 'seed') { seed(args[0] /* files */) } if (action === 'openPlayer') { - openPlayer(args[0] /* torrent */) + openPlayer(args[0] /* infoHash */) + } + if (action === 'toggleTorrent') { + toggleTorrent(args[0] /* infoHash */) } if (action === 'deleteTorrent') { - deleteTorrent(args[0] /* torrent */) + deleteTorrent(args[0] /* infoHash */) } if (action === 'openChromecast') { - openChromecast(args[0] /* torrent */) + openChromecast(args[0] /* infoHash */) } if (action === 'openAirplay') { - openAirplay(args[0] /* torrent */) + openAirplay(args[0] /* infoHash */) } if (action === 'setDimensions') { setDimensions(args[0] /* dimensions */) @@ -226,14 +223,17 @@ function loadState (callback) { electron.ipcRenderer.send('log', 'loaded state from ' + cfg.filePath) // populate defaults if they're not there - state.saved = extend(defaultSaved, data) + state.saved = Object.assign({}, state.defaultSavedState, data) if (callback) callback() }) } -function resumeAllTorrents () { - state.saved.torrents.forEach((x) => startTorrenting(x.infoHash)) +// Starts all torrents that aren't paused on program startup +function resumeTorrents () { + state.saved.torrents + .filter((x) => x.status !== 'paused') + .forEach((x) => startTorrenting(x.infoHash)) } // Write state.saved to the JSON state file @@ -279,57 +279,60 @@ function isNotTorrentFile (file) { return !isTorrentFile(file) } +// Gets a torrent summary {name, infoHash, status} from state.saved.torrents +// Returns undefined if we don't know that infoHash +function getTorrentSummary (infoHash) { + 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) { + return state.client.torrents.find((x) => x.infoHash === infoHash) +} + // 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) { - if (!torrentId) torrentId = 'magnet:?xt=urn:btih:6a9759bffd5c0af65319979fb7832189f4f3c35d&dn=sintel.mp4' + // Charlie Chaplin: 'magnet:?xt=urn:btih:cddf0459a718523480f7499da5ed1a504cffecb8&dn=charlie%5Fchaplin%5Ffilm%5Ffestival' + if (!torrentId) torrentId = 'magnet:?xt=urn:btih:6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5&dn=CosmosLaundromatFirstCycle' + var torrent = startTorrenting(torrentId) - // check if torrent is duplicate - var exists = state.saved.torrents.find((x) => x.infoHash === torrent.infoHash) - if (exists) return window.alert('That torrent is already downloading.') + // If torrentId is a torrent file, wait for WebTorrent to finish reading it + if (!torrent.infoHash) torrent.on('infoHash', addTorrentToList) + else addTorrentToList() - // save only if infoHash is available - if (torrent.infoHash) { + function addTorrentToList () { + if (getTorrentSummary(torrent.infoHash)) { + return // Skip, torrent is already in state.saved + } state.saved.torrents.push({ + status: 'new', + name: torrent.name, + magnetURI: torrent.magnetURI, infoHash: torrent.infoHash }) - } else { - torrent.on('infoHash', () => saveTorrentData(torrent)) + saveState() } - - saveState() } -// add torrent metadata to state once it's available -function saveTorrentData (torrent) { - var ix = state.saved.torrents.findIndex((x) => x.infoHash === torrent.infoHash) - var data = { - name: torrent.name, - magnetURI: torrent.magnetURI, - infoHash: torrent.infoHash, - path: torrent.path, - xt: torrent.xt, - dn: torrent.dn, - announce: torrent.announce - } - - if (ix === -1) state.saved.torrents.push(data) - else state.saved.torrents[ix] = data - - saveState() -} - -// Starts downloading and/or seeding a given torrent file or magnet URI -function startTorrenting (torrentId) { - var torrent = state.client.add(torrentId, { - // use downloads folder +// Starts downloading and/or seeding a given torrent, torrentSummary or magnet URI +function startTorrenting (infoHash) { + var torrent = state.client.add(infoHash, { + // Use downloads folder path: state.saved.downloadPath }) addTorrentEvents(torrent) return torrent } +// Stops downloading and/or seeding. See startTorrenting +function stopTorrenting (infoHash) { + var torrent = getTorrent(infoHash) + if (torrent) torrent.destroy() +} + // Creates a torrent for a local file and starts seeding it function seed (files) { if (files.length === 0) return @@ -339,33 +342,60 @@ function seed (files) { function addTorrentEvents (torrent) { torrent.on('infoHash', update) - torrent.on('ready', torrentReady) torrent.on('done', torrentDone) - update() - function torrentReady () { - torrentPoster(torrent, function (err, buf) { - if (err) return onWarning(err) - torrent.posterURL = URL.createObjectURL(new Blob([ buf ], { type: 'image/png' })) - update() - }) + var torrentSummary = getTorrentSummary(torrent.infoHash) + torrentSummary.status = 'downloading' + torrentSummary.ready = true + torrentSummary.name = torrent.name + torrentSummary.infoHash = torrent.infoHash + + if (!torrentSummary.posterURL) { + generateTorrentPoster(torrent, torrentSummary) + } + update() } function torrentDone () { + var torrentSummary = getTorrentSummary(torrent.infoHash) + torrentSummary.status = 'seeding' + if (!state.isFocused) { state.dock.badge += 1 electron.ipcRenderer.send('setBadge', state.dock.badge) } + update() } } -function startServer (torrent, cb) { +function generateTorrentPoster (torrent, torrentSummary) { + torrentPoster(torrent, function (err, buf) { + if (err) return onWarning(err) + // save it for next time + var posterFilePath = path.join(cfgDirectory, torrent.infoHash + '.jpg') + fs.writeFile(posterFilePath, buf, function (err) { + if (err) return onWarning(err) + // show the poster + torrentSummary.posterURL = 'file:///' + posterFilePath + update() + }) + }) +} + +function startServer (infoHash, cb) { if (state.server) return cb() + var torrent = getTorrent(infoHash) + if (!torrent) torrent = startTorrenting(infoHash) + if (torrent.ready) startServerFromReadyTorrent(torrent, cb) + else torrent.on('ready', () => startServerFromReadyTorrent(torrent, cb)) +} + +function startServerFromReadyTorrent (torrent, cb) { // use largest file state.torrentPlaying = torrent.files.reduce(function (a, b) { return a.length > b.length ? a : b @@ -385,15 +415,15 @@ function startServer (torrent, cb) { }) } -function closeServer () { +function stopServer () { state.server.server.destroy() state.server = null } -function openPlayer (torrent) { - startServer(torrent, function () { +function openPlayer (infoHash) { + startServer(infoHash, function () { state.url = '/player' - state.title = torrent.name + /* TODO: set state.title to the clean name of the torrent */ update() }) } @@ -405,20 +435,36 @@ function closePlayer () { electron.ipcRenderer.send('toggleFullScreen') } restoreBounds() - closeServer() + stopServer() update() } -function deleteTorrent (torrent) { - var ix = state.saved.torrents.findIndex((x) => x.infoHash === torrent.infoHash) - if (ix > -1) state.saved.torrents.splice(ix, 1) - torrent.destroy(saveState) +function toggleTorrent (infoHash) { + var torrentSummary = getTorrentSummary(infoHash) + if (!torrentSummary) return + if (torrentSummary.status === 'paused') { + torrentSummary.status = 'new' + startTorrenting(torrentSummary.infoHash) + } else { + torrentSummary.status = 'paused' + stopTorrenting(torrentSummary.infoHash) + } } -function openChromecast (torrent) { - startServer(torrent, function () { +function deleteTorrent (infoHash) { + var torrent = getTorrent(infoHash) + torrent.destroy() + + var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash) + if (index > -1) state.saved.torrents.splice(index, 1) + saveState() +} + +function openChromecast (infoHash) { + var torrentSummary = getTorrentSummary(infoHash) + startServer(infoHash, function () { state.devices.chromecast.play(state.server.networkURL, { - title: config.APP_NAME + ' — ' + torrent.name + title: config.APP_NAME + ' — ' + torrentSummary.name }) state.devices.chromecast.on('error', function (err) { err.message = 'Chromecast: ' + err.message @@ -428,8 +474,8 @@ function openChromecast (torrent) { }) } -function openAirplay (torrent) { - startServer(torrent, function () { +function openAirplay (infoHash) { + startServer(infoHash, function () { state.devices.airplay.play(state.server.networkURL, 0, function () { // TODO: handle airplay errors }) diff --git a/renderer/state.js b/renderer/state.js index e5830e4b..4fb84a45 100644 --- a/renderer/state.js +++ b/renderer/state.js @@ -1,3 +1,6 @@ +var os = require('os') +var path = require('path') + var config = require('../config') module.exports = { @@ -44,5 +47,31 @@ module.exports = { */ saved: { torrents: [] + }, + + /* If the saved state file doesn't exist yet, here's what we use instead */ + defaultSavedState: { + version: 1, /* make sure we can upgrade gracefully later */ + torrents: [ + { + status: 'paused', + infoHash: 'f84b51f0d2c3455ab5dabb6643b4340234cd036e', + displayName: 'Big Buck Bunny', + posterURL: '../resources/bigBuckBunny.jpg' + }, + { + status: 'paused', + infoHash: '6a9759bffd5c0af65319979fb7832189f4f3c35d', + displayName: 'Sintel', + posterURL: '../resources/sintel.jpg' + }, + { + status: 'paused', + infoHash: '02767050e0be2fd4db9a2ad6c12416ac806ed6ed', + displayName: 'Tears of Steel', + posterURL: '../resources/tearsOfSteel.jpg' + } + ], + downloadPath: path.join(os.homedir(), 'Downloads') } } diff --git a/renderer/views/torrent-list.js b/renderer/views/torrent-list.js index 6321753b..d139882f 100644 --- a/renderer/views/torrent-list.js +++ b/renderer/views/torrent-list.js @@ -7,7 +7,8 @@ var hx = hyperx(h) var prettyBytes = require('pretty-bytes') function TorrentList (state, dispatch) { - var list = state.client.torrents.map((torrent) => renderTorrent(torrent, dispatch)) + var list = state.saved.torrents.map( + (torrentSummary) => renderTorrent(torrentSummary, state, dispatch)) if (list.length === 0) list = emptyList() return hx`
${list}
` } @@ -24,76 +25,93 @@ function emptyList () { // Renders a torrent in the torrent list // Includes name, download status, play button, background image // May be expanded for additional info, including the list of files inside -function renderTorrent (torrent, dispatch) { +function renderTorrent (torrentSummary, state, dispatch) { + // Get ephemeral data (like progress %) directly from the WebTorrent handle + var torrent = state.client.torrents.find((x) => x.infoHash === torrentSummary.infoHash) + // Background image: show some nice visuals, like a frame from the movie, if possible var style = {} - if (torrent.posterURL) { - style['background-image'] = `linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%), url("${torrent.posterURL}")` + if (torrentSummary.posterURL) { + style['background-image'] = 'linear-gradient(to bottom, ' + + 'rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%), ' + + `url("${torrentSummary.posterURL}")` } // Foreground: name of the torrent, basic info like size, play button, // cast buttons if available, and delete return hx`
- ${renderTorrentMetadata(torrent)} - ${renderTorrentButtons(torrent)} + ${renderTorrentMetadata(torrent, torrentSummary)} + ${renderTorrentButtons(torrentSummary, dispatch)}
` +} - function renderTorrentMetadata () { +// Show name, download status, % complete +function renderTorrentMetadata (torrent, torrentSummary) { + var name = torrentSummary.displayName || torrentSummary.name || 'Loading torrent...' + var elements = [hx` +
${name}
+ `] + + // 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 (downloaded !== total) downloaded += ` / ${total}` - return hx` -
-
${torrent.name || 'Loading torrent...'}
-
- ${getFilesLength()} - ${getPeers()} - ↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s - ↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s -
-
- ${progress}% - ${downloaded} -
+ elements.push(hx` +
+ ${getFilesLength()} + ${getPeers()} + ↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s + ↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s
- ` - - function getPeers () { - var count = torrent.numPeers === 1 ? 'peer' : 'peers' - return `${torrent.numPeers} ${count}` - } - - function getFilesLength () { - if (torrent.ready && torrent.files.length > 1) { - return hx`${torrent.files.length} files` - } - } + `) + elements.push(hx` +
+ ${progress}% + ${downloaded} +
+ `) } - function renderTorrentButtons () { - return hx` -
- dispatch('toggleTorrent', torrent)}> - file_download - - dispatch('openPlayer', torrent)}> - play_arrow - - dispatch('deleteTorrent', torrent)}> - close - -
- ` + return hx`
${elements}
` + + function getPeers () { + var count = torrent.numPeers === 1 ? 'peer' : 'peers' + return `${torrent.numPeers} ${count}` + } + + function getFilesLength () { + if (torrent.ready && torrent.files.length > 1) { + return hx`${torrent.files.length} files` + } } } + +// Download button toggles between torrenting (DL/seed) and paused +// Play button starts streaming the torrent immediately, unpausing if needed +function renderTorrentButtons (torrentSummary, dispatch) { + var infoHash = torrentSummary.infoHash + return hx` +
+ dispatch('toggleTorrent', infoHash)}> + file_download + + dispatch('openPlayer', infoHash)}> + play_arrow + + dispatch('deleteTorrent', infoHash)}> + close + +
+ ` +} diff --git a/resources/bigBuckBunny.jpg b/resources/bigBuckBunny.jpg new file mode 100644 index 00000000..8961b593 Binary files /dev/null and b/resources/bigBuckBunny.jpg differ diff --git a/resources/sintel.jpg b/resources/sintel.jpg new file mode 100644 index 00000000..17a19374 Binary files /dev/null and b/resources/sintel.jpg differ diff --git a/resources/tearsOfSteel.jpg b/resources/tearsOfSteel.jpg new file mode 100644 index 00000000..7f4175b9 Binary files /dev/null and b/resources/tearsOfSteel.jpg differ