diff --git a/.gitignore b/.gitignore index 76add878..ab57381f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -dist \ No newline at end of file +build +dist diff --git a/AUTHORS.md b/AUTHORS.md index fe8d32e4..034d67e2 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -25,5 +25,6 @@ - anonymlol - Gediminas Petrikas - Adam Gotlib +- Rémi Jouannet #### Generated by bin/update-authors.sh. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b323a86..5db417f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # WebTorrent Desktop Version History +## v0.9.0 - 2016-07-20 + +### Added +- Save selected subtitles +- Ask for confirmation before deleting torrents +- Support Debian Jessie + +### Changed +- Only send telemetry in production +- Clean up the code. Split main.js, refactor lots of things + +### Fixed +- Fix state.playing.jumpToTime behavior +- Remove torrent file and poster image when deleting a torrent + ## v0.8.1 - 2016-06-24 ### Added diff --git a/README.md b/README.md index 878eaf45..592f0ae6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@
-

The streaming torrent client. For OS X, Windows, and Linux.

+

The streaming torrent client. For Mac, Windows, and Linux.

Gitter @@ -22,7 +22,7 @@ ## Screenshot

- screenshot + screenshot

## How to Contribute @@ -41,7 +41,7 @@ $ npm start ### Package app -Builds app binaries for OS X, Linux, and Windows. +Builds app binaries for Mac, Linux, and Windows. ``` $ npm run package @@ -57,23 +57,23 @@ Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default). The following optional arguments are available: -- `--sign` - Sign the application (OS X, Windows) +- `--sign` - Sign the application (Mac, Windows) - `--package=[type]` - Package single output type. - `deb` - Debian package - `zip` - Linux zip file - - `dmg` - OS X disk image + - `dmg` - Mac disk image - `exe` - Windows installer - `portable` - Windows portable app - `all` - All platforms (default) -Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, *-darwin.zip for OS X) will always be produced. +Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, *-darwin.zip for Mac) will always be produced. #### Windows build notes To package the Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs to be installed. -On OS X, first install [XQuartz](http://www.xquartz.org/), then run: +On Mac, first install [XQuartz](http://www.xquartz.org/), then run: ``` brew install wine diff --git a/bin/check-deps.js b/bin/check-deps.js index d17e4d09..f5e9e42c 100755 --- a/bin/check-deps.js +++ b/bin/check-deps.js @@ -45,7 +45,7 @@ var BUILT_IN_ELECTRON_MODULES = [ 'electron' ] var BUILT_IN_DEPS = [].concat(BUILT_IN_NODE_MODULES, BUILT_IN_ELECTRON_MODULES) -var EXECUTABLE_DEPS = ['gh-release', 'standard'] +var EXECUTABLE_DEPS = ['gh-release', 'standard', 'react-tools'] main() diff --git a/bin/clean.js b/bin/clean.js index d88f6e82..2bf78c24 100755 --- a/bin/clean.js +++ b/bin/clean.js @@ -10,11 +10,17 @@ var os = require('os') var path = require('path') var rimraf = require('rimraf') -var config = require('../config') -var handlers = require('../main/handlers') +var config = require('../src/config') +var handlers = require('../src/main/handlers') +// First, remove generated files +rimraf.sync('build/') +rimraf.sync('dist/') + +// Remove any saved configuration rimraf.sync(config.CONFIG_PATH) +// Remove any temporary files var tmpPath try { tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent') diff --git a/bin/list-deps.sh b/bin/list-deps.sh index ac492cd4..b1ff6c32 100755 --- a/bin/list-deps.sh +++ b/bin/list-deps.sh @@ -2,7 +2,7 @@ # This is a truly heinous hack, but it works pretty nicely. # Find all modules we're requiring---even conditional requires. -grep "require('" *.js bin/ main/ renderer/ -R | +grep "require('" src/ bin/ -R | grep '.js:' | sed "s/.*require('\([^'\/]*\).*/\1/" | grep -v '^\.' | diff --git a/bin/open-config.js b/bin/open-config.js index 3c9a5990..e2756e15 100755 --- a/bin/open-config.js +++ b/bin/open-config.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -var config = require('../config') +var config = require('../src/config') var open = require('open') open(config.CONFIG_PATH) diff --git a/bin/package.js b/bin/package.js index 876fd6e9..4007bd91 100755 --- a/bin/package.js +++ b/bin/package.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Builds app binaries for OS X, Linux, and Windows. + * Builds app binaries for Mac, Linux, and Windows. */ var cp = require('child_process') @@ -15,7 +15,7 @@ var rimraf = require('rimraf') var series = require('run-series') var zip = require('cross-zip') -var config = require('../config') +var config = require('../src/config') var pkg = require('../package.json') var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION @@ -56,11 +56,11 @@ function build () { var all = { // The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata - // property on Windows, and `NSHumanReadableCopyright` on OS X. + // property on Windows, and `NSHumanReadableCopyright` on Mac. 'app-copyright': config.APP_COPYRIGHT, // The release version of the application. Maps to the `ProductVersion` metadata - // property on Windows, and `CFBundleShortVersionString` on OS X. + // property on Windows, and `CFBundleShortVersionString` on Mac. 'app-version': pkg.version, // Package the application's source code into an archive, using Electron's archive @@ -73,7 +73,7 @@ var all = { 'asar-unpack': 'WebTorrent*', // The build version of the application. Maps to the FileVersion metadata property on - // Windows, and CFBundleVersion on OS X. Note: Windows requires the build version to + // Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to // start with a number. We're using the version of the underlying WebTorrent library. 'build-version': require('webtorrent/package.json').version, @@ -102,20 +102,20 @@ var all = { } var darwin = { - // Build for OS X + // Build for Mac platform: 'darwin', // Build 64 bit binaries only. arch: 'x64', - // The bundle identifier to use in the application's plist (OS X only). + // The bundle identifier to use in the application's plist (Mac only). 'app-bundle-id': 'io.webtorrent.webtorrent', // The application category type, as shown in the Finder via "View" -> "Arrange by - // Application Category" when viewing the Applications directory (OS X only). + // Application Category" when viewing the Applications directory (Mac only). 'app-category-type': 'public.app-category.utilities', - // The bundle identifier to use in the application helper's plist (OS X only). + // The bundle identifier to use in the application helper's plist (Mac only). 'helper-bundle-id': 'io.webtorrent.webtorrent-helper', // Application icon. @@ -171,10 +171,10 @@ build() function buildDarwin (cb) { var plist = require('plist') - console.log('OS X: Packaging electron...') + console.log('Mac: Packaging electron...') electronPackager(Object.assign({}, all, darwin), function (err, buildPath) { if (err) return cb(err) - console.log('OS X: Packaged electron. ' + buildPath) + console.log('Mac: Packaged electron. ' + buildPath) var appPath = path.join(buildPath[0], config.APP_NAME + '.app') var contentsPath = path.join(appPath, 'Contents') @@ -261,9 +261,9 @@ function buildDarwin (cb) { * - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by * the same author as the current version. * - So users will not a see a warning about the app coming from an "Unidentified - * Developer" when they open it for the first time (OS X Gatekeeper). + * Developer" when they open it for the first time (Mac Gatekeeper). * - * To sign an OS X app for distribution outside the App Store, the following are + * To sign an Mac app for distribution outside the App Store, the following are * required: * - Xcode * - Xcode Command Line Tools (xcode-select --install) @@ -275,10 +275,10 @@ function buildDarwin (cb) { verbose: true } - console.log('OS X: Signing app...') + console.log('Mac: Signing app...') sign(signOpts, function (err) { if (err) return cb(err) - console.log('OS X: Signed app.') + console.log('Mac: Signed app.') cb(null) }) } @@ -293,24 +293,24 @@ function buildDarwin (cb) { function packageZip () { // Create .zip file (used by the auto-updater) - console.log('OS X: Creating zip...') + console.log('Mac: Creating zip...') var inPath = path.join(buildPath[0], config.APP_NAME + '.app') var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip') zip.zipSync(inPath, outPath) - console.log('OS X: Created zip.') + console.log('Mac: Created zip.') } function packageDmg (cb) { - console.log('OS X: Creating dmg...') + console.log('Mac: Creating dmg...') var appDmg = require('appdmg') var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg') rimraf.sync(targetPath) - // Create a .dmg (OS X disk image) file, for easy user installation. + // Create a .dmg (Mac disk image) file, for easy user installation. var dmgOpts = { basepath: config.ROOT_PATH, target: targetPath, @@ -338,7 +338,7 @@ function buildDarwin (cb) { if (info.type === 'step-begin') console.log(info.title + '...') }) dmg.once('finish', function (info) { - console.log('OS X: Created dmg.') + console.log('Mac: Created dmg.') cb(null) }) } diff --git a/index.js b/index.js index 140c9430..67582133 100644 --- a/index.js +++ b/index.js @@ -1 +1 @@ -require('./main') +require('./build/main') diff --git a/package.json b/package.json index e5797752..492e1a2b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webtorrent-desktop", - "description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.", - "version": "0.8.1", + "description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.", + "version": "0.9.0", "author": { "name": "WebTorrent, LLC", "email": "feross@webtorrent.io", @@ -15,29 +15,30 @@ }, "dependencies": { "airplayer": "^2.0.0", - "application-config": "^0.2.1", + "application-config": "^1.0.0", "bitfield": "^1.0.2", "chromecasts": "^1.8.0", "create-torrent": "^3.24.5", "deep-equal": "^1.0.1", "dlnacasts": "^0.1.0", "drag-drop": "^2.11.0", - "electron-prebuilt": "1.2.1", - "fs-extra": "^0.27.0", - "hyperx": "^2.0.2", + "electron-prebuilt": "1.3.1", + "fs-extra": "^0.30.0", + "hat": "0.0.3", "iso-639-1": "^1.2.1", "languagedetect": "^1.1.1", - "main-loop": "^3.2.0", + "location-history": "^1.0.0", "musicmetadata": "^2.0.2", "network-address": "^1.1.0", "parse-torrent": "^5.7.3", "prettier-bytes": "^1.0.1", + "react": "^15.2.1", + "react-dom": "^15.2.1", "run-parallel": "^1.1.6", "semver": "^5.1.0", "simple-concat": "^1.0.0", "simple-get": "^2.0.0", "srt-to-vtt": "^1.1.1", - "virtual-dom": "^2.1.1", "vlc-command": "^1.0.1", "webtorrent": "0.x", "winreg": "^1.2.0", @@ -54,10 +55,14 @@ "nobin-debian-installer": "^0.0.10", "open": "0.0.5", "plist": "^1.2.0", + "react-tools": "^0.13.3", "rimraf": "^2.5.2", "run-series": "^1.1.4", "standard": "^7.0.0" }, + "engines": { + "node": ">=4.0.0" + }, "homepage": "https://webtorrent.io", "keywords": [ "desktop", @@ -65,8 +70,8 @@ "electron-app", "hybrid webtorrent client", "mad science", - "torrent client", "torrent", + "torrent client", "webtorrent" ], "license": "MIT", @@ -82,8 +87,8 @@ "scripts": { "clean": "node ./bin/clean.js", "open-config": "node ./bin/open-config.js", - "package": "node ./bin/package.js", - "start": "electron .", + "package": "rm -rf build/ && jsx --es6module src/ build/ && node ./bin/package.js", + "start": "jsx --es6module src/ build/ && electron .", "test": "standard && node ./bin/check-deps.js", "update-authors": "./bin/update-authors.sh" } diff --git a/renderer/lib/hx.js b/renderer/lib/hx.js deleted file mode 100644 index d1434273..00000000 --- a/renderer/lib/hx.js +++ /dev/null @@ -1,5 +0,0 @@ -var h = require('virtual-dom/h') -var hyperx = require('hyperx') -var hx = hyperx(h) - -module.exports = hx diff --git a/renderer/lib/location-history.js b/renderer/lib/location-history.js deleted file mode 100644 index 2c5d88f3..00000000 --- a/renderer/lib/location-history.js +++ /dev/null @@ -1,126 +0,0 @@ -module.exports = LocationHistory - -function LocationHistory () { - if (!new.target) return new LocationHistory() - this._history = [] - this._forward = [] - this._pending = false -} - -LocationHistory.prototype.url = function () { - return this.current() && this.current().url -} - -LocationHistory.prototype.current = function () { - return this._history[this._history.length - 1] -} - -LocationHistory.prototype.go = function (page, cb) { - if (!cb) cb = noop - if (this._pending) return cb(null) - - console.log('go', page) - - this.clearForward() - this._go(page, cb) -} - -LocationHistory.prototype.back = function (cb) { - var self = this - if (!cb) cb = noop - if (self._history.length <= 1 || self._pending) return cb(null) - - var page = self._history.pop() - self._unload(page, done) - - function done (err) { - if (err) return cb(err) - self._forward.push(page) - self._load(self.current(), cb) - } -} - -LocationHistory.prototype.hasBack = function () { - return this._history.length > 1 -} - -LocationHistory.prototype.forward = function (cb) { - if (!cb) cb = noop - if (this._forward.length === 0 || this._pending) return cb(null) - - var page = this._forward.pop() - this._go(page, cb) -} - -LocationHistory.prototype.hasForward = function () { - return this._forward.length > 0 -} - -LocationHistory.prototype.clearForward = function (url) { - if (url == null) { - this._forward = [] - } else { - console.log(this._forward) - console.log(url) - this._forward = this._forward.filter(function (page) { - return page.url !== url - }) - } -} - -LocationHistory.prototype.backToFirst = function (cb) { - var self = this - if (!cb) cb = noop - if (self._history.length <= 1) return cb(null) - - self.back(function (err) { - if (err) return cb(err) - self.backToFirst(cb) - }) -} - -LocationHistory.prototype._go = function (page, cb) { - var self = this - if (!cb) cb = noop - - self._unload(self.current(), done1) - - function done1 (err) { - if (err) return cb(err) - self._load(page, done2) - } - - function done2 (err) { - if (err) return cb(err) - self._history.push(page) - cb(null) - } -} - -LocationHistory.prototype._load = function (page, cb) { - var self = this - self._pending = true - - if (page && page.onbeforeload) page.onbeforeload(done) - else done(null) - - function done (err) { - self._pending = false - cb(err) - } -} - -LocationHistory.prototype._unload = function (page, cb) { - var self = this - self._pending = true - - if (page && page.onbeforeunload) page.onbeforeunload(done) - else done(null) - - function done (err) { - self._pending = false - cb(err) - } -} - -function noop () {} diff --git a/renderer/main.html b/renderer/main.html deleted file mode 100644 index e2f14ed4..00000000 --- a/renderer/main.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/renderer/views/app.js b/renderer/views/app.js deleted file mode 100644 index 58c64b2d..00000000 --- a/renderer/views/app.js +++ /dev/null @@ -1,83 +0,0 @@ -module.exports = App - -var hx = require('../lib/hx') -var Header = require('./header') - -var Views = { - 'home': require('./torrent-list'), - 'player': require('./player'), - 'create-torrent': require('./create-torrent'), - 'preferences': require('./preferences') -} - -var Modals = { - 'open-torrent-address-modal': require('./open-torrent-address-modal'), - 'remove-torrent-modal': require('./remove-torrent-modal'), - 'update-available-modal': require('./update-available-modal'), - 'unsupported-media-modal': require('./unsupported-media-modal') -} - -function App (state) { - // Hide player controls while playing video, if the mouse stays still for a while - // Never hide the controls when: - // * The mouse is over the controls or we're scrubbing (see CSS) - // * The video is paused - // * The video is playing remotely on Chromecast or Airplay - var hideControls = state.location.url() === 'player' && - state.playing.mouseStationarySince !== 0 && - new Date().getTime() - state.playing.mouseStationarySince > 2000 && - !state.playing.isPaused && - state.playing.location === 'local' && - state.playing.playbackRate === 1 - - var cls = [ - 'view-' + state.location.url(), /* e.g. view-home, view-player */ - 'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */ - ] - if (state.window.isFullScreen) cls.push('is-fullscreen') - if (state.window.isFocused) cls.push('is-focused') - if (hideControls) cls.push('hide-video-controls') - - return hx` -
- ${Header(state)} - ${getErrorPopover(state)} -
${getView(state)}
- ${getModal(state)} -
- ` -} - -function getErrorPopover (state) { - var now = new Date().getTime() - var recentErrors = state.errors.filter((x) => now - x.time < 5000) - var hasErrors = recentErrors.length > 0 - - var errorElems = recentErrors.map(function (error) { - return hx`
${error.message}
` - }) - return hx` -
-
Error
- ${errorElems} -
- ` -} - -function getModal (state) { - if (!state.modal) return - var contents = Modals[state.modal.id](state) - return hx` - - ` -} - -function getView (state) { - var url = state.location.url() - return Views[url](state) -} diff --git a/renderer/views/create-torrent.js b/renderer/views/create-torrent.js deleted file mode 100644 index 42585431..00000000 --- a/renderer/views/create-torrent.js +++ /dev/null @@ -1,149 +0,0 @@ -module.exports = CreateTorrentPage - -var createTorrent = require('create-torrent') -var path = require('path') -var prettyBytes = require('prettier-bytes') - -var {dispatch, dispatcher} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function CreateTorrentPage (state) { - var info = state.location.current() - - // Preprocess: exclude .DS_Store and other dotfiles - var files = info.files - .filter((f) => !f.name.startsWith('.')) - .map((f) => ({name: f.name, path: f.path, size: f.size})) - if (files.length === 0) return CreateTorrentErrorPage() - - // First, extract the base folder that the files are all in - var pathPrefix = info.folderPath - if (!pathPrefix) { - pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix) - if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) { - pathPrefix = path.dirname(pathPrefix) - } - } - - // Sanity check: show the number of files and total size - var numFiles = files.length - var totalBytes = files - .map((f) => f.size) - .reduce((a, b) => a + b, 0) - var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}` - - // 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, basePath - if (files.length === 1) { - // Single file torrent: /a/b/foo.jpg -> torrent name "foo.jpg", path "/a/b" - defaultName = files[0].name - basePath = pathPrefix - } else { - // Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name "b", path "/a" - defaultName = path.basename(pathPrefix) - basePath = path.dirname(pathPrefix) - } - var maxFileElems = 100 - var fileElems = files.slice(0, maxFileElems).map(function (file) { - var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path) - return hx`
${relativePath}
` - }) - if (files.length > maxFileElems) { - fileElems.push(hx`
+ ${maxFileElems - files.length} more
`) - } - var trackers = createTorrent.announceList.join('\n') - var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed' - - return hx` -
-

Create torrent ${defaultName}

-

- ${torrentInfo} -

-

- -

${pathPrefix}
-

-
- ${info.showAdvanced ? 'Basic' : 'Advanced'} -
-
-

- - -

-

- - -

-

- - -

-

- -

${fileElems}
-

-
-

- - -

-
- ` - - function handleOK () { - var announceList = document.querySelector('.torrent-trackers').value - .split('\n') - .map((s) => s.trim()) - .filter((s) => s !== '') - var isPrivate = document.querySelector('.torrent-is-private').checked - var comment = document.querySelector('.torrent-comment').value.trim() - var options = { - // 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. - // If we ever want to add support for that: - // name: document.querySelector('.torrent-name').value - name: defaultName, - path: basePath, - files: files, - announce: announceList, - private: isPrivate, - comment: comment - } - dispatch('createTorrent', options) - } -} - -function CreateTorrentErrorPage () { - return hx` -
-

Create torrent

-

-

- Sorry, you must select at least one file that is not a hidden file. -

-

- Hidden files, starting with a . character, are not included. -

-

-

- -

-
- ` -} - -// 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) -} diff --git a/renderer/views/header.js b/renderer/views/header.js deleted file mode 100644 index 21b9f0c7..00000000 --- a/renderer/views/header.js +++ /dev/null @@ -1,48 +0,0 @@ -module.exports = Header - -var {dispatcher} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function Header (state) { - return hx` -
- ${getTitle()} - - -
- ` - - function getTitle () { - if (process.platform === 'darwin') { - return hx`
${state.window.title}
` - } - } - - function getAddButton () { - if (state.location.url() === 'home') { - return hx` - - add - - ` - } - } -} diff --git a/renderer/views/open-torrent-address-modal.js b/renderer/views/open-torrent-address-modal.js deleted file mode 100644 index a2d5c7de..00000000 --- a/renderer/views/open-torrent-address-modal.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = OpenTorrentAddressModal - -var {dispatch, dispatcher} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function OpenTorrentAddressModal (state) { - return hx` -
-

-

- -

-

- - -

- -
- ` -} - -function handleKeyPress (e) { - if (e.which === 13) handleOK() /* hit Enter to submit */ -} - -function handleOK () { - dispatch('exitModal') - dispatch('addTorrent', document.querySelector('#add-torrent-url').value) -} diff --git a/renderer/views/remove-torrent-modal.js b/renderer/views/remove-torrent-modal.js deleted file mode 100644 index 580adac5..00000000 --- a/renderer/views/remove-torrent-modal.js +++ /dev/null @@ -1,26 +0,0 @@ -module.exports = RemoveTorrentModal - -var {dispatch, dispatcher} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function RemoveTorrentModal (state) { - var message = state.modal.deleteData - ? 'Are you sure you want to remove this torrent from the list and delete the data file?' - : 'Are you sure you want to remove this torrent from the list?' - var buttonText = state.modal.deleteData ? 'Remove Data' : 'Remove' - - return hx` -
-

${message}

-

- - -

-
- ` - - function handleRemove () { - dispatch('deleteTorrent', state.modal.infoHash, state.modal.deleteData) - dispatch('exitModal') - } -} diff --git a/renderer/views/unsupported-media-modal.js b/renderer/views/unsupported-media-modal.js deleted file mode 100644 index a6bb9d5c..00000000 --- a/renderer/views/unsupported-media-modal.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = UnsupportedMediaModal - -var electron = require('electron') - -var {dispatch, dispatcher} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function UnsupportedMediaModal (state) { - var err = state.modal.error - var message = (err && err.getMessage) - ? err.getMessage() - : err - var actionButton = state.modal.vlcInstalled - ? hx`` - : hx`` - var vlcMessage = state.modal.vlcNotFound - ? 'Couldn\'t run VLC. Please make sure it\'s installed.' - : '' - return hx` -
-

Sorry, we can't play that file.

-

${message}

-

- - ${actionButton} -

-

${vlcMessage}

-
- ` - - function onInstall () { - electron.shell.openExternal('http://www.videolan.org/vlc/') - state.modal.vlcInstalled = true // Assume they'll install it successfully - } - - function onPlay () { - dispatch('vlcPlay') - } -} diff --git a/renderer/views/update-available-modal.js b/renderer/views/update-available-modal.js deleted file mode 100644 index 941251c3..00000000 --- a/renderer/views/update-available-modal.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = UpdateAvailableModal - -var electron = require('electron') - -var {dispatch} = require('../lib/dispatcher') -var hx = require('../lib/hx') - -function UpdateAvailableModal (state) { - return hx` -
-

A new version of WebTorrent is available: v${state.modal.version}

-

We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so you'll have to download the new version manually.

-

- - -

-
- ` - - function handleOK () { - electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases') - dispatch('exitModal') - } - - function handleCancel () { - dispatch('skipVersion', state.modal.version) - dispatch('exitModal') - } -} diff --git a/config.js b/src/config.js similarity index 85% rename from config.js rename to src/config.js index d01b5390..2848ea68 100644 --- a/config.js +++ b/src/config.js @@ -4,7 +4,7 @@ var path = require('path') var APP_NAME = 'WebTorrent' var APP_TEAM = 'WebTorrent, LLC' -var APP_VERSION = require('./package.json').version +var APP_VERSION = require('../package.json').version var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings') @@ -15,8 +15,8 @@ module.exports = { TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry', APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM, - APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'), - APP_ICON: path.join(__dirname, 'static', 'WebTorrent'), + APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'), + APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'), APP_NAME: APP_NAME, APP_TEAM: APP_TEAM, APP_VERSION: APP_VERSION, @@ -66,13 +66,13 @@ module.exports = { IS_PRODUCTION: isProduction(), POSTER_PATH: path.join(getConfigPath(), 'Posters'), - ROOT_PATH: __dirname, - STATIC_PATH: path.join(__dirname, 'static'), + ROOT_PATH: path.join(__dirname, '..'), + STATIC_PATH: path.join(__dirname, '..', 'static'), TORRENT_PATH: path.join(getConfigPath(), 'Torrents'), - WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'), - WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html'), - WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'), + WINDOW_ABOUT: 'file://' + path.join(__dirname, '..', 'static', 'about.html'), + WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'), + WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'), WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents WINDOW_MIN_WIDTH: 425 diff --git a/crash-reporter.js b/src/crash-reporter.js similarity index 100% rename from crash-reporter.js rename to src/crash-reporter.js diff --git a/main/announcement.js b/src/main/announcement.js similarity index 100% rename from main/announcement.js rename to src/main/announcement.js diff --git a/main/dialog.js b/src/main/dialog.js similarity index 95% rename from main/dialog.js rename to src/main/dialog.js index 177e1ecc..b1ef94a8 100644 --- a/main/dialog.js +++ b/src/main/dialog.js @@ -8,7 +8,6 @@ module.exports = { var electron = require('electron') -var config = require('../config') var log = require('./log') var windows = require('./windows') @@ -109,7 +108,7 @@ function openTorrentAddress () { } /** - * Dialogs on do not show a title on OS X, so the window title is used instead. + * Dialogs on do not show a title on Mac, so the window title is used instead. */ function setTitle (title) { if (process.platform === 'darwin') { @@ -118,5 +117,5 @@ function setTitle (title) { } function resetTitle () { - setTitle(config.APP_WINDOW_TITLE) + windows.main.dispatch('resetTitle') } diff --git a/main/dock.js b/src/main/dock.js similarity index 81% rename from main/dock.js rename to src/main/dock.js index 2d17a296..be05ac34 100644 --- a/main/dock.js +++ b/src/main/dock.js @@ -12,7 +12,7 @@ var dialog = require('./dialog') var log = require('./log') /** - * Add a right-click menu to the dock icon. (OS X) + * Add a right-click menu to the dock icon. (Mac) */ function init () { if (!app.dock) return @@ -21,7 +21,7 @@ function init () { } /** - * Bounce the Downloads stack if `path` is inside the Downloads folder. (OS X) + * Bounce the Downloads stack if `path` is inside the Downloads folder. (Mac) */ function downloadFinished (path) { if (!app.dock) return @@ -30,12 +30,11 @@ function downloadFinished (path) { } /** - * Display string in dock badging area. (OS X) + * Display a counter badge for the app. (Mac, Linux) */ -function setBadge (text) { - if (!app.dock) return - log(`setBadge: ${text}`) - app.dock.setBadge(String(text)) +function setBadge (count) { + log(`setBadge: ${count}`) + app.setBadgeCount(Number(count)) } function getMenuTemplate () { diff --git a/main/handlers.js b/src/main/handlers.js similarity index 99% rename from main/handlers.js rename to src/main/handlers.js index 454871a2..4224b408 100644 --- a/main/handlers.js +++ b/src/main/handlers.js @@ -34,7 +34,7 @@ function installDarwin () { var electron = require('electron') var app = electron.app - // On OS X, only protocols that are listed in `Info.plist` can be set as the + // On Mac, only protocols that are listed in `Info.plist` can be set as the // default handler at runtime. app.setAsDefaultProtocolClient('magnet') app.setAsDefaultProtocolClient('stream-magnet') diff --git a/main/index.js b/src/main/index.js similarity index 95% rename from main/index.js rename to src/main/index.js index 3be191cc..c81517e1 100644 --- a/main/index.js +++ b/src/main/index.js @@ -22,9 +22,11 @@ var windows = require('./windows') var shouldQuit = false var argv = sliceArgv(process.argv) +if (!argv.includes('--dev')) process.env.NODE_ENV = 'production' + if (process.platform === 'win32') { shouldQuit = squirrelWin32.handleEvent(argv[0]) - argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1) + argv = argv.filter((arg) => !arg.includes('--squirrel')) } if (!shouldQuit) { @@ -151,7 +153,7 @@ function processArgv (argv) { } else if (arg === '-u') { dialog.openTorrentAddress() } else if (arg.startsWith('-psn')) { - // Ignore OS X launchd "process serial number" argument + // Ignore Mac launchd "process serial number" argument // Issue: https://github.com/feross/webtorrent-desktop/issues/214 } else { torrentIds.push(arg) diff --git a/main/ipc.js b/src/main/ipc.js similarity index 97% rename from main/ipc.js rename to src/main/ipc.js index 99b76a6a..eb4ba876 100644 --- a/main/ipc.js +++ b/src/main/ipc.js @@ -115,8 +115,8 @@ function init () { }) }) - ipc.on('vlcPlay', function (e, url) { - var args = ['--play-and-exit', '--video-on-top', '--no-video-title-show', '--quiet', url] + ipc.on('vlcPlay', function (e, url, title) { + var args = ['--play-and-exit', '--video-on-top', '--no-video-title-show', '--quiet', `--meta-title=${title}`, url] log('Running vlc ' + args.join(' ')) vlc.spawn(args, function (err, proc) { diff --git a/main/log.js b/src/main/log.js similarity index 100% rename from main/log.js rename to src/main/log.js diff --git a/main/menu.js b/src/main/menu.js similarity index 90% rename from main/menu.js rename to src/main/menu.js index f8fac0c9..d68a6dfc 100644 --- a/main/menu.js +++ b/src/main/menu.js @@ -99,10 +99,6 @@ function getMenuTemplate () { type: 'separator' }, { - label: process.platform === 'win32' - ? 'Close' - : 'Close Window', - accelerator: 'CmdOrCtrl+W', role: 'close' } ] @@ -111,23 +107,28 @@ function getMenuTemplate () { label: 'Edit', submenu: [ { - label: 'Cut', - accelerator: 'CmdOrCtrl+X', + role: 'undo' + }, + { + role: 'redo' + }, + { + type: 'separator' + }, + { role: 'cut' }, { - label: 'Copy', - accelerator: 'CmdOrCtrl+C', role: 'copy' }, { label: 'Paste Torrent Address', - accelerator: 'CmdOrCtrl+V', role: 'paste' }, { - label: 'Select All', - accelerator: 'CmdOrCtrl+A', + role: 'delete' + }, + { role: 'selectall' }, { @@ -280,12 +281,11 @@ function getMenuTemplate () { ] if (process.platform === 'darwin') { - // Add WebTorrent app menu (OS X) + // Add WebTorrent app menu (Mac) template.unshift({ label: config.APP_NAME, submenu: [ { - label: 'About ' + config.APP_NAME, role: 'about' }, { @@ -300,7 +300,6 @@ function getMenuTemplate () { type: 'separator' }, { - label: 'Services', role: 'services', submenu: [] }, @@ -308,45 +307,34 @@ function getMenuTemplate () { type: 'separator' }, { - label: 'Hide ' + config.APP_NAME, - accelerator: 'Command+H', role: 'hide' }, { - label: 'Hide Others', - accelerator: 'Command+Alt+H', role: 'hideothers' }, { - label: 'Show All', role: 'unhide' }, { type: 'separator' }, { - label: 'Quit', - accelerator: 'Command+Q', - click: () => app.quit() + role: 'quit' } ] }) - // Add Window menu (OS X) + // Add Window menu (Mac) template.splice(5, 0, { - label: 'Window', role: 'window', submenu: [ { - label: 'Minimize', - accelerator: 'CmdOrCtrl+M', role: 'minimize' }, { type: 'separator' }, { - label: 'Bring All to Front', role: 'front' } ] diff --git a/main/power-save-blocker.js b/src/main/power-save-blocker.js similarity index 100% rename from main/power-save-blocker.js rename to src/main/power-save-blocker.js diff --git a/main/shell.js b/src/main/shell.js similarity index 100% rename from main/shell.js rename to src/main/shell.js diff --git a/main/shortcuts.js b/src/main/shortcuts.js similarity index 100% rename from main/shortcuts.js rename to src/main/shortcuts.js diff --git a/main/squirrel-win32.js b/src/main/squirrel-win32.js similarity index 100% rename from main/squirrel-win32.js rename to src/main/squirrel-win32.js diff --git a/main/thumbar.js b/src/main/thumbar.js similarity index 100% rename from main/thumbar.js rename to src/main/thumbar.js diff --git a/main/tray.js b/src/main/tray.js similarity index 97% rename from main/tray.js rename to src/main/tray.js index 391d1739..22a55b69 100644 --- a/main/tray.js +++ b/src/main/tray.js @@ -21,7 +21,7 @@ function init () { if (process.platform === 'win32') { initWin32() } - // OS X apps generally do not have menu bar icons + // Mac apps generally do not have menu bar icons } /** diff --git a/main/updater.js b/src/main/updater.js similarity index 100% rename from main/updater.js rename to src/main/updater.js diff --git a/main/vlc.js b/src/main/vlc.js similarity index 100% rename from main/vlc.js rename to src/main/vlc.js diff --git a/main/windows/about.js b/src/main/windows/about.js similarity index 100% rename from main/windows/about.js rename to src/main/windows/about.js diff --git a/main/windows/index.js b/src/main/windows/index.js similarity index 100% rename from main/windows/index.js rename to src/main/windows/index.js diff --git a/main/windows/main.js b/src/main/windows/main.js similarity index 96% rename from main/windows/main.js rename to src/main/windows/main.js index 4ae5dc95..bad4b522 100644 --- a/main/windows/main.js +++ b/src/main/windows/main.js @@ -37,7 +37,7 @@ function init () { minWidth: config.WINDOW_MIN_WIDTH, minHeight: config.WINDOW_MIN_HEIGHT, title: config.APP_WINDOW_TITLE, - titleBarStyle: 'hidden-inset', // Hide title bar (OS X) + titleBarStyle: 'hidden-inset', // Hide title bar (Mac) useContentSize: true, // Specify web page size without OS chrome width: 500, height: HEADER_HEIGHT + (TORRENT_HEIGHT * 6) // header height + 5 torrents @@ -95,7 +95,7 @@ function send (...args) { } /** - * Enforce window aspect ratio. Remove with 0. (OS X) + * Enforce window aspect ratio. Remove with 0. (Mac) */ function setAspectRatio (aspectRatio) { if (!main.win) return @@ -196,7 +196,7 @@ function toggleFullScreen (flag) { log(`toggleFullScreen ${flag}`) if (flag) { - // Fullscreen and aspect ratio do not play well together. (OS X) + // Fullscreen and aspect ratio do not play well together. (Mac) main.win.setAspectRatio(0) } diff --git a/main/windows/webtorrent.js b/src/main/windows/webtorrent.js similarity index 100% rename from main/windows/webtorrent.js rename to src/main/windows/webtorrent.js diff --git a/renderer/controllers/media-controller.js b/src/renderer/controllers/media-controller.js similarity index 93% rename from renderer/controllers/media-controller.js rename to src/renderer/controllers/media-controller.js index 785b168b..1de07781 100644 --- a/renderer/controllers/media-controller.js +++ b/src/renderer/controllers/media-controller.js @@ -43,7 +43,7 @@ module.exports = class MediaController { } vlcPlay () { - ipcRenderer.send('vlcPlay', this.state.server.localURL) + ipcRenderer.send('vlcPlay', this.state.server.localURL, this.state.window.title) this.state.playing.location = 'vlc' } diff --git a/renderer/controllers/playback-controller.js b/src/renderer/controllers/playback-controller.js similarity index 56% rename from renderer/controllers/playback-controller.js rename to src/renderer/controllers/playback-controller.js index 7df428dc..a8ba601e 100644 --- a/renderer/controllers/playback-controller.js +++ b/src/renderer/controllers/playback-controller.js @@ -28,11 +28,11 @@ module.exports = class PlaybackController { playFile (infoHash, index /* optional */) { this.state.location.go({ url: 'player', - onbeforeload: (cb) => { + setup: (cb) => { this.play() - openPlayer(this.state, infoHash, index, cb) + this.openPlayer(infoHash, index, cb) }, - onbeforeunload: (cb) => closePlayer(this.state, this.config, cb) + destroy: () => this.closePlayer() }, (err) => { if (err) dispatch('error', err) }) @@ -40,7 +40,7 @@ module.exports = class PlaybackController { // Show a file in the OS, eg in Finder on a Mac openItem (infoHash, index) { - var torrentSummary = torrentSummary.getByKey(this.state, infoHash) + var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) var filePath = path.join( torrentSummary.path, torrentSummary.files[index].path) @@ -54,8 +54,8 @@ module.exports = class PlaybackController { // force rerendering if window is hidden, // in order to bypass `raf` and play/pause media immediately - if (!state.window.isVisible) { - var mediaTag = document.querySelector('video,audio') + var mediaTag = document.querySelector('video,audio') + if (!state.window.isVisible && mediaTag) { if (state.playing.isPaused) mediaTag.play() else mediaTag.pause() } @@ -162,146 +162,146 @@ module.exports = class PlaybackController { } return false } -} -// Opens the video player to a specific torrent -function openPlayer (state, infoHash, index, cb) { - var torrentSummary = TorrentSummary.getByKey(state, infoHash) + // Opens the video player to a specific torrent + openPlayer (infoHash, index, cb) { + var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) - // automatically choose which file in the torrent to play, if necessary - if (index === undefined) index = torrentSummary.defaultPlayFileIndex - if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files) - if (index === undefined) return cb(new errors.UnplayableError()) + // automatically choose which file in the torrent to play, if necessary + if (index === undefined) index = torrentSummary.defaultPlayFileIndex + if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files) + if (index === undefined) return cb(new errors.UnplayableError()) - // update UI to show pending playback - if (torrentSummary.progress !== 1) sound.play('PLAY') - // TODO: remove torrentSummary.playStatus - torrentSummary.playStatus = 'requested' - this.update() - - var timeout = setTimeout(() => { - telemetry.logPlayAttempt('timeout') + // update UI to show pending playback + if (torrentSummary.progress !== 1) sound.play('PLAY') // TODO: remove torrentSummary.playStatus - torrentSummary.playStatus = 'timeout' /* no seeders available? */ - sound.play('ERROR') - cb(new Error('Playback timed out. Try again.')) + torrentSummary.playStatus = 'requested' this.update() - }, 10000) /* give it a few seconds */ - if (torrentSummary.status === 'paused') { - dispatch('startTorrentingSummary', torrentSummary) - ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, - () => openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb)) - } else { - openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb) + var timeout = setTimeout(() => { + telemetry.logPlayAttempt('timeout') + // TODO: remove torrentSummary.playStatus + torrentSummary.playStatus = 'timeout' /* no seeders available? */ + sound.play('ERROR') + cb(new Error('Playback timed out. Try again.')) + this.update() + }, 10000) /* give it a few seconds */ + + if (torrentSummary.status === 'paused') { + dispatch('startTorrentingSummary', torrentSummary) + ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, + () => this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)) + } else { + this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb) + } } -} function getOpenInVlc () { return this.state.saved.prefs.playInVlc } -function openPlayerFromActiveTorrent (state, torrentSummary, index, timeout, cb) { - var fileSummary = torrentSummary.files[index] + 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' + // update state + var state = this.state + state.playing.infoHash = torrentSummary.infoHash + state.playing.fileIndex = index + state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video' + : TorrentPlayer.isAudio(fileSummary) ? 'audio' + : 'other' - // pick up where we left off - if (fileSummary.currentTime) { - var fraction = fileSummary.currentTime / fileSummary.duration - var secondsLeft = fileSummary.duration - fileSummary.currentTime - if (fraction < 0.9 && secondsLeft > 10) { - state.playing.jumpToTime = fileSummary.currentTime + // pick up where we left off + if (fileSummary.currentTime) { + var fraction = fileSummary.currentTime / fileSummary.duration + var secondsLeft = fileSummary.duration - fileSummary.currentTime + if (fraction < 0.9 && secondsLeft > 10) { + state.playing.jumpToTime = fileSummary.currentTime + } } - } - // if it's audio, parse out the metadata (artist, title, etc) - if (state.playing.type === 'audio' && !fileSummary.audioInfo) { - ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) - } - - // if it's video, check for subtitles files that are done downloading - dispatch('checkForSubtitles') - - // enable previously selected subtitle track - if (fileSummary.selectedSubtitle) { - dispatch('addSubtitles', [fileSummary.selectedSubtitle], true) - } - - ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index) - ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => { - clearTimeout(timeout) - - // if we timed out (user clicked play a long time ago), don't autoplay - var timedOut = torrentSummary.playStatus === 'timeout' - delete torrentSummary.playStatus - if (timedOut) { - ipcRenderer.send('wt-stop-server') - return this.update() + // if it's audio, parse out the metadata (artist, title, etc) + if (state.playing.type === 'audio' && !fileSummary.audioInfo) { + ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) } + // if it's video, check for subtitles files that are done downloading + dispatch('checkForSubtitles') + + // enable previously selected subtitle track + if (fileSummary.selectedSubtitle) { + dispatch('addSubtitles', [fileSummary.selectedSubtitle], true) + } + + ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index) + ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => { + clearTimeout(timeout) + + // if we timed out (user clicked play a long time ago), don't autoplay + var timedOut = torrentSummary.playStatus === 'timeout' + delete torrentSummary.playStatus + if (timedOut) { + ipcRenderer.send('wt-stop-server') + return this.update() + } + + // otherwise, play the video + dispatch('setTitle', torrentSummary.files[state.playing.fileIndex].name) + this.update() + + ipcRenderer.send('onPlayerOpen') + cb() + }) + } + // play in VLC if set as default player (Preferences / Playback / Play in VLC) if (getOpenInVlc()) { dispatch('vlcPlay') this.update() cb() - return + return } - // otherwise, play the video - state.window.title = torrentSummary.files[state.playing.fileIndex].name + closePlayer () { + console.log('closePlayer') + + // Quit any external players, like Chromecast/Airplay/etc or VLC + var state = this.state + if (isCasting(state)) { + Cast.stop() + } + if (state.playing.location === 'vlc') { + ipcRenderer.send('vlcQuit') + } + + // Save volume (this session only, not in state.saved) + state.previousVolume = state.playing.volume + + // Telemetry: track what happens after the user clicks play + var result = state.playing.result // 'success' or 'error' + if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed + else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc + else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame + else console.error('Unknown state.playing.result', state.playing.result) + + // Reset the window contents back to the home screen + state.playing = State.getDefaultPlayState() + state.server = null + + // Reset the window size and location back to where it was + if (state.window.isFullScreen) { + dispatch('toggleFullScreen', false) + } + restoreBounds(state) + + // Tell the WebTorrent process to kill the torrent-to-HTTP server + ipcRenderer.send('wt-stop-server') + + ipcRenderer.send('onPlayerClose') + this.update() - - ipcRenderer.send('onPlayerOpen') - - cb() - }) -} - -function closePlayer (state, config, cb) { - console.log('closePlayer') - - // Quit any external players, like Chromecast/Airplay/etc or VLC - if (isCasting(state)) { - Cast.stop() } - if (state.playing.location === 'vlc') { - ipcRenderer.send('vlcQuit') - } - - // Save volume (this session only, not in state.saved) - state.previousVolume = state.playing.volume - - // Telemetry: track what happens after the user clicks play - var result = state.playing.result // 'success' or 'error' - if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed - else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc - else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame - else console.error('Unknown state.playing.result', state.playing.result) - - // Reset the window contents back to the home screen - state.window.title = config.APP_WINDOW_TITLE - state.playing = State.getDefaultPlayState() - state.server = null - - // Reset the window size and location back to where it was - if (state.window.isFullScreen) { - dispatch('toggleFullScreen', false) - } - restoreBounds(state) - - // Tell the WebTorrent process to kill the torrent-to-HTTP server - ipcRenderer.send('wt-stop-server') - - ipcRenderer.send('onPlayerClose') - - this.update() - cb() } // Checks whether we are connected and already casting diff --git a/renderer/controllers/prefs-controller.js b/src/renderer/controllers/prefs-controller.js similarity index 75% rename from renderer/controllers/prefs-controller.js rename to src/renderer/controllers/prefs-controller.js index 9979a2b0..9ba400bf 100644 --- a/renderer/controllers/prefs-controller.js +++ b/src/renderer/controllers/prefs-controller.js @@ -1,3 +1,4 @@ +const {dispatch} = require('../lib/dispatcher') const State = require('../lib/state') // Controls the Preferences screen @@ -12,24 +13,19 @@ module.exports = class PrefsController { var state = this.state state.location.go({ url: 'preferences', - onbeforeload: function (cb) { + setup: function (cb) { // initialize preferences - state.window.title = 'Preferences' + dispatch('setTitle', 'Preferences') state.unsaved = Object.assign(state.unsaved || {}, {prefs: state.saved.prefs || {}}) cb() }, - onbeforeunload: (cb) => { - // save state after preferences - this.save() - state.window.title = this.config.APP_WINDOW_TITLE - cb() - } + destroy: () => this.save() }) } // Updates a single property in the UNSAVED prefs - // For example: updatePreferences("foo.bar", "baz") - // Call savePreferences to save to config.json + // For example: updatePreferences('foo.bar', 'baz') + // Call save() to save to config.json update (property, value) { var path = property.split('.') var key = this.state.unsaved.prefs diff --git a/renderer/controllers/subtitles-controller.js b/src/renderer/controllers/subtitles-controller.js similarity index 95% rename from renderer/controllers/subtitles-controller.js rename to src/renderer/controllers/subtitles-controller.js index e1cb1425..cb63d7ce 100644 --- a/renderer/controllers/subtitles-controller.js +++ b/src/renderer/controllers/subtitles-controller.js @@ -116,17 +116,17 @@ function loadSubtitle (file, cb) { }) } -// Checks whether a language name like "English" or "German" matches the system +// Checks whether a language name like 'English' or 'German' matches the system // language, aka the current locale function isSystemLanguage (language) { var iso639 = require('iso-639-1') - var osLangISO = window.navigator.language.split('-')[0] // eg "en" - var langIso = iso639.getCode(language) // eg "de" if language is "German" + var osLangISO = window.navigator.language.split('-')[0] // eg 'en' + var langIso = iso639.getCode(language) // eg 'de' if language is 'German' return langIso === osLangISO } // Make sure we don't have two subtitle tracks with the same label -// Labels each track by language, eg "German", "English", "English 2", ... +// Labels each track by language, eg 'German', 'English', 'English 2', ... function relabelSubtitles (subtitles) { var counts = {} subtitles.tracks.forEach(function (track) { diff --git a/src/renderer/controllers/torrent-controller.js b/src/renderer/controllers/torrent-controller.js new file mode 100644 index 00000000..2cfd56e5 --- /dev/null +++ b/src/renderer/controllers/torrent-controller.js @@ -0,0 +1,190 @@ +const path = require('path') +const ipcRenderer = require('electron').ipcRenderer + +const TorrentSummary = require('../lib/torrent-summary') +const TorrentPlayer = require('../lib/torrent-player') +const sound = require('../lib/sound') +const {dispatch} = require('../lib/dispatcher') + +module.exports = class TorrentController { + constructor (state) { + this.state = state + } + + torrentInfoHash (torrentKey, infoHash) { + var torrentSummary = this.getTorrentSummary(torrentKey) + console.log('got infohash for %s torrent %s', + torrentSummary ? 'existing' : 'new', torrentKey) + + if (!torrentSummary) { + var torrents = this.state.saved.torrents + + // Check if an existing (non-active) torrent has the same info hash + if (torrents.find((t) => t.infoHash === infoHash)) { + ipcRenderer.send('wt-stop-torrenting', infoHash) + return dispatch('error', 'Cannot add duplicate torrent') + } + + torrentSummary = { + torrentKey: torrentKey, + status: 'new' + } + torrents.unshift(torrentSummary) + sound.play('ADD') + } + + torrentSummary.infoHash = infoHash + dispatch('update') + } + + torrentWarning (torrentKey, message) { + console.log('warning for torrent %s: %s', torrentKey, message) + } + + torrentError (torrentKey, message) { + // TODO: WebTorrent needs semantic errors + if (message.startsWith('Cannot add duplicate torrent')) { + // Remove infohash from the message + message = 'Cannot add duplicate torrent' + } + dispatch('error', message) + + var torrentSummary = this.getTorrentSummary(torrentKey) + if (torrentSummary) { + console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message) + torrentSummary.status = 'paused' + dispatch('update') + } + } + + torrentMetadata (torrentKey, torrentInfo) { + // Summarize torrent + var torrentSummary = this.getTorrentSummary(torrentKey) + torrentSummary.status = 'downloading' + torrentSummary.name = torrentSummary.displayName || torrentInfo.name + torrentSummary.path = torrentInfo.path + torrentSummary.magnetURI = torrentInfo.magnetURI + // TODO: make torrentInfo immutable, save separately as torrentSummary.info + // For now, check whether torrentSummary.files has already been set: + var hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path + if (!hasDetailedFileInfo) { + torrentSummary.files = torrentInfo.files + } + if (!torrentSummary.selections) { + torrentSummary.selections = torrentSummary.files.map((x) => true) + } + torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files) + dispatch('update') + + // Save the .torrent file, if it hasn't been saved already + if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey) + + // Auto-generate a poster image, if it hasn't been generated already + if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey) + } + + torrentDone (torrentKey, torrentInfo) { + // Update the torrent summary + var torrentSummary = this.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 (!this.state.window.isFocused) { + this.state.dock.badge += 1 + } + showDoneNotification(torrentSummary) + ipcRenderer.send('downloadFinished', getTorrentPath(torrentSummary)) + } + + dispatch('update') + } + + torrentProgress (progressInfo) { + // Overall progress across all active torrents, 0 to 1 + var progress = progressInfo.progress + var hasActiveTorrents = progressInfo.hasActiveTorrents + + // Hide progress bar when client has no torrents, or progress is 100% + // TODO: isn't this equivalent to: if (progress === 1) ? + if (!hasActiveTorrents || progress === 1) { + progress = -1 + } + + // Show progress bar under the WebTorrent taskbar icon, on OSX + this.state.dock.progress = progress + + // Update progress for each individual torrent + progressInfo.torrents.forEach((p) => { + var torrentSummary = this.getTorrentSummary(p.torrentKey) + if (!torrentSummary) { + console.log('warning: got progress for missing torrent %s', p.torrentKey) + return + } + torrentSummary.progress = p + }) + + // TODO: Find an efficient way to re-enable this line, which allows subtitle + // files which are completed after a video starts to play to be added + // dynamically to the list of subtitles. + // checkForSubtitles() + } + + torrentFileModtimes (torrentKey, fileModtimes) { + var torrentSummary = this.getTorrentSummary(torrentKey) + torrentSummary.fileModtimes = fileModtimes + dispatch('saveStateThrottled') + } + + torrentFileSaved (torrentKey, torrentFileName) { + console.log('torrent file saved %s: %s', torrentKey, torrentFileName) + var torrentSummary = this.getTorrentSummary(torrentKey) + torrentSummary.torrentFileName = torrentFileName + dispatch('saveStateThrottled') + } + + torrentPosterSaved (torrentKey, posterFileName) { + var torrentSummary = this.getTorrentSummary(torrentKey) + torrentSummary.posterFileName = posterFileName + dispatch('saveStateThrottled') + } + + torrentAudioMetadata (infoHash, index, info) { + var torrentSummary = this.getTorrentSummary(infoHash) + var fileSummary = torrentSummary.files[index] + fileSummary.audioInfo = info + dispatch('update') + } + + torrentServerRunning (serverInfo) { + this.state.server = serverInfo + } + + // Gets a torrent summary {name, infoHash, status} from state.saved.torrents + // Returns undefined if we don't know that infoHash + getTorrentSummary (torrentKey) { + return TorrentSummary.getByKey(this.state, torrentKey) + } +} + +function getTorrentPath (torrentSummary) { + var itemPath = TorrentSummary.getFileOrFolder(torrentSummary) + if (torrentSummary.files.length > 1) { + itemPath = path.dirname(itemPath) + } + return itemPath +} + +function showDoneNotification (torrent) { + var notif = new window.Notification('Download Complete', { + body: torrent.name, + silent: true + }) + + notif.onClick = function () { + ipcRenderer.send('show') + } + + sound.play('DONE') +} diff --git a/renderer/controllers/torrent-list-controller.js b/src/renderer/controllers/torrent-list-controller.js similarity index 100% rename from renderer/controllers/torrent-list-controller.js rename to src/renderer/controllers/torrent-list-controller.js diff --git a/renderer/controllers/update-controller.js b/src/renderer/controllers/update-controller.js similarity index 100% rename from renderer/controllers/update-controller.js rename to src/renderer/controllers/update-controller.js diff --git a/renderer/lib/capture-video-frame.js b/src/renderer/lib/capture-video-frame.js similarity index 100% rename from renderer/lib/capture-video-frame.js rename to src/renderer/lib/capture-video-frame.js diff --git a/renderer/lib/cast.js b/src/renderer/lib/cast.js similarity index 100% rename from renderer/lib/cast.js rename to src/renderer/lib/cast.js diff --git a/renderer/lib/dispatcher.js b/src/renderer/lib/dispatcher.js similarity index 91% rename from renderer/lib/dispatcher.js rename to src/renderer/lib/dispatcher.js index eba84df7..a17f1a5c 100644 --- a/renderer/lib/dispatcher.js +++ b/src/renderer/lib/dispatcher.js @@ -17,7 +17,7 @@ function dispatch (...args) { // Most DOM event handlers are trivial functions like `() => dispatch()`. // For these, `dispatcher()` is preferred because it memoizes the handler -// function. This prevents virtual-dom from updating the listener functions on +// function. This prevents React from updating the listener functions on // each update(). function dispatcher (...args) { var str = JSON.stringify(args) diff --git a/renderer/lib/errors.js b/src/renderer/lib/errors.js similarity index 100% rename from renderer/lib/errors.js rename to src/renderer/lib/errors.js diff --git a/renderer/lib/migrations.js b/src/renderer/lib/migrations.js similarity index 98% rename from renderer/lib/migrations.js rename to src/renderer/lib/migrations.js index 424f7626..7c1aa8a6 100644 --- a/renderer/lib/migrations.js +++ b/src/renderer/lib/migrations.js @@ -10,7 +10,7 @@ var config = require('../../config') // Change `state.saved` (which will be saved back to config.json on exit) as // needed, for example to deal with config.json format changes across versions function run (state) { - // Replace "{ version: 1 }" with app version (semver) + // Replace '{ version: 1 }' with app version (semver) if (!semver.valid(state.saved.version)) { state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations } diff --git a/renderer/lib/sound.js b/src/renderer/lib/sound.js similarity index 100% rename from renderer/lib/sound.js rename to src/renderer/lib/sound.js diff --git a/renderer/lib/state.js b/src/renderer/lib/state.js similarity index 98% rename from renderer/lib/state.js rename to src/renderer/lib/state.js index 2fd1ba82..3205e1df 100644 --- a/renderer/lib/state.js +++ b/src/renderer/lib/state.js @@ -15,7 +15,7 @@ var State = module.exports = Object.assign(new EventEmitter(), { appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json') function getDefaultState () { - var LocationHistory = require('./location-history') + var LocationHistory = require('location-history') return { /* @@ -50,7 +50,7 @@ function getDefaultState () { * * Config path: * - * OS X ~/Library/Application Support/WebTorrent/config.json + * Mac ~/Library/Application Support/WebTorrent/config.json * Linux (XDG) $XDG_CONFIG_HOME/WebTorrent/config.json * Linux (Legacy) ~/.config/WebTorrent/config.json * Windows (> Vista) %LOCALAPPDATA%/WebTorrent/config.json @@ -82,6 +82,7 @@ function getDefaultPlayState () { lastTimeUpdate: 0, /* Unix time in ms */ mouseStationarySince: 0, /* Unix time in ms */ playbackRate: 1, + volume: 1, subtitles: { tracks: [], /* subtitle tracks, each {label, language, ...} */ selectedIndex: -1, /* current subtitle track */ diff --git a/renderer/lib/telemetry.js b/src/renderer/lib/telemetry.js similarity index 99% rename from renderer/lib/telemetry.js rename to src/renderer/lib/telemetry.js index 7d9203aa..23893492 100644 --- a/renderer/lib/telemetry.js +++ b/src/renderer/lib/telemetry.js @@ -140,7 +140,7 @@ function logUncaughtError (procName, err) { } // The user pressed play. It either worked, timed out, or showed the -// "Play in VLC" codec error +// 'Play in VLC' codec error function logPlayAttempt (result) { if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) { return console.error('Unknown play attempt result', result) diff --git a/renderer/lib/torrent-player.js b/src/renderer/lib/torrent-player.js similarity index 55% rename from renderer/lib/torrent-player.js rename to src/renderer/lib/torrent-player.js index fe81b020..c2ee4003 100644 --- a/renderer/lib/torrent-player.js +++ b/src/renderer/lib/torrent-player.js @@ -2,21 +2,21 @@ module.exports = { isPlayable, isVideo, isAudio, - isPlayableTorrent, + isTorrent, + isPlayableTorrentSummary, pickFileToPlay } var path = require('path') -/** - * Determines whether a file in a torrent is audio/video we can play - */ +// Checks whether a fileSummary or file path is audio/video that we can play, +// based on the file extension function isPlayable (file) { return isVideo(file) || isAudio(file) } +// Checks whether a fileSummary or file path is playable video function isVideo (file) { - var ext = path.extname(file.name).toLowerCase() return [ '.avi', '.m4v', @@ -27,21 +27,36 @@ function isVideo (file) { '.ogv', '.webm', '.wmv' - ].includes(ext) + ].includes(getFileExtension(file)) } +// Checks whether a fileSummary or file path is playable audio function isAudio (file) { - var ext = path.extname(file.name).toLowerCase() return [ '.aac', '.ac3', '.mp3', '.ogg', '.wav' - ].includes(ext) + ].includes(getFileExtension(file)) } -function isPlayableTorrent (torrentSummary) { +// Checks if the argument is either: +// - a string that's a valid filename ending in .torrent +// - a file object where obj.name is ends in .torrent +// - a string that's a magnet link (magnet://...) +function isTorrent (file) { + var isTorrentFile = getFileExtension(file) === '.torrent' + var isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file) + return isTorrentFile || isMagnet +} + +function getFileExtension (file) { + var name = typeof file === 'string' ? file : file.name + return path.extname(name).toLowerCase() +} + +function isPlayableTorrentSummary (torrentSummary) { return torrentSummary.files && torrentSummary.files.some(isPlayable) } diff --git a/renderer/lib/torrent-poster.js b/src/renderer/lib/torrent-poster.js similarity index 100% rename from renderer/lib/torrent-poster.js rename to src/renderer/lib/torrent-poster.js diff --git a/renderer/lib/torrent-summary.js b/src/renderer/lib/torrent-summary.js similarity index 100% rename from renderer/lib/torrent-summary.js rename to src/renderer/lib/torrent-summary.js diff --git a/renderer/main.js b/src/renderer/main.js similarity index 60% rename from renderer/main.js rename to src/renderer/main.js index c5a60f0e..b131d997 100644 --- a/renderer/main.js +++ b/src/renderer/main.js @@ -5,12 +5,8 @@ crashReporter.init() const dragDrop = require('drag-drop') const electron = require('electron') -const mainLoop = require('main-loop') -const path = require('path') - -const createElement = require('virtual-dom/create-element') -const diff = require('virtual-dom/diff') -const patch = require('virtual-dom/patch') +const React = require('react') +const ReactDOM = require('react-dom') const config = require('../config') const App = require('./views/app') @@ -18,14 +14,14 @@ const telemetry = require('./lib/telemetry') const sound = require('./lib/sound') const State = require('./lib/state') const TorrentPlayer = require('./lib/torrent-player') -const TorrentSummary = require('./lib/torrent-summary') const MediaController = require('./controllers/media-controller') const UpdateController = require('./controllers/update-controller') const PrefsController = require('./controllers/prefs-controller') -const TorrentListController = require('./controllers/torrent-list-controller') const PlaybackController = require('./controllers/playback-controller') const SubtitlesController = require('./controllers/subtitles-controller') +const TorrentListController = require('./controllers/torrent-list-controller') +const TorrentController = require('./controllers/torrent-controller') // Yo-yo pattern: state object lives here and percolates down thru all the views. // Events come back up from the views via dispatch(...) @@ -44,7 +40,10 @@ var ipcRenderer = electron.ipcRenderer // 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. -var state, vdomLoop +var state + +// Root React component +var app State.load(onState) @@ -53,20 +52,27 @@ State.load(onState) // the dock icon and drag+drop. function onState (err, _state) { if (err) return onError(err) - state = _state + state = window.state = _state // Make available for easier debugging // Create controllers controllers = { media: new MediaController(state), update: new UpdateController(state), prefs: new PrefsController(state, config), - torrentList: new TorrentListController(state), playback: new PlaybackController(state, config, update), - subtitles: new SubtitlesController(state) + subtitles: new SubtitlesController(state), + torrentList: new TorrentListController(state), + torrent: new TorrentController(state) } // Add first page to location history - state.location.go({ url: 'home' }) + state.location.go({ + url: 'home', + setup: (cb) => { + state.window.title = config.APP_WINDOW_TITLE + cb(null) + } + }) // Restart everything we were torrenting last time the app ran resumeTorrents() @@ -74,17 +80,6 @@ function onState (err, _state) { // Lazy-load other stuff, like the AppleTV module, later to keep startup fast window.setTimeout(delayedInit, config.DELAYED_INIT) - // The UI is built with virtual-dom, a minimalist library extracted from React - // The concepts--one way data flow, a pure function that renders state to a - // virtual DOM tree, and a diff that applies changes in the vdom to the real - // DOM, are all the same. Learn more: https://facebook.github.io/react/ - vdomLoop = mainLoop(state, render, { - create: createElement, - diff: diff, - patch: patch - }) - document.body.appendChild(vdomLoop.target) - // Listen for messages from the main process setupIpc() @@ -92,6 +87,7 @@ function onState (err, _state) { // Do this at least once a second to give every file in every torrentSummary // a progress bar and to keep the cursor in sync when playing a video setInterval(update, 1000) + app = ReactDOM.render(, document.querySelector('#body')) // OS integrations: // ...drag and drop a torrent or video file to play or seed @@ -100,7 +96,7 @@ function onState (err, _state) { // ...same thing if you paste a torrent document.addEventListener('paste', onPaste) - // ...focus and blur. Needed to show correct dock icon text ("badge") in OSX + // ...focus and blur. Needed to show correct dock icon text ('badge') in OSX window.addEventListener('focus', onFocus) window.addEventListener('blur', onBlur) @@ -132,20 +128,14 @@ function lazyLoadCast () { return Cast } -// This is the (mostly) pure function from state -> UI. Returns a virtual DOM -// tree. Any events, such as button clicks, will turn into calls to dispatch() -function render (state) { - try { - return App(state) - } catch (e) { - console.log('rendering error: %s\n\t%s', e.message, e.stack) - } -} - -// Calls render() to go from state -> UI, then applies to vdom to the real DOM. +// React loop: +// 1. update() - recompute the virtual DOM, diff, apply to the real DOM +// 2. event - might be a click or other DOM event, or something external +// 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller +// 4. controller - the controller handles the event, changing the state object function update () { controllers.playback.showOrHidePlayerControls() - vdomLoop.update(state) + app.setState(state) updateElectron() } @@ -162,7 +152,7 @@ function updateElectron () { } if (state.dock.badge !== state.prev.badge) { state.prev.badge = state.dock.badge - ipcRenderer.send('setBadge', state.dock.badge || '') + ipcRenderer.send('setBadge', state.dock.badge || 0) } } @@ -235,18 +225,21 @@ const dispatchHandlers = { 'setDimensions': setDimensions, 'toggleFullScreen': (setTo) => ipcRenderer.send('toggleFullScreen', setTo), 'setTitle': (title) => { state.window.title = title }, + 'resetTitle': () => { state.window.title = config.APP_WINDOW_TITLE }, // Everything else 'onOpen': onOpen, 'error': onError, 'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err), - 'saveState': () => State.save(state) + 'saveState': () => State.save(state), + 'saveStateThrottled': () => State.saveThrottled(state), + 'update': () => {} // No-op, just trigger an update } // Events from the UI never modify state directly. Instead they call dispatch() function dispatch (action, ...args) { - // Log dispatch calls, for debugging - if (!['mediaMouseMoved', 'mediaTimeUpdate'].includes(action)) { + // Log dispatch calls, for debugging, but don't spam + if (!['mediaMouseMoved', 'mediaTimeUpdate', 'update'].includes(action)) { console.log('dispatch: %s %o', action, args) } @@ -254,7 +247,7 @@ function dispatch (action, ...args) { if (handler) handler(...args) else console.error('Missing dispatch handler: ' + action) - // 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' || controllers.playback.showOrHidePlayerControls()) { update() @@ -270,18 +263,19 @@ function setupIpc () { ipcRenderer.on('fullscreenChanged', onFullscreenChanged) - ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args)) - ipcRenderer.on('wt-metadata', (e, ...args) => torrentMetadata(...args)) - ipcRenderer.on('wt-done', (e, ...args) => torrentDone(...args)) - ipcRenderer.on('wt-warning', (e, ...args) => torrentWarning(...args)) - ipcRenderer.on('wt-error', (e, ...args) => torrentError(...args)) + var tc = controllers.torrent + ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args)) + ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args)) + ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args)) + ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args)) + ipcRenderer.on('wt-error', (e, ...args) => tc.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)) + ipcRenderer.on('wt-progress', (e, ...args) => tc.torrentProgress(...args)) + ipcRenderer.on('wt-file-modtimes', (e, ...args) => tc.torrentFileModtimes(...args)) + ipcRenderer.on('wt-file-saved', (e, ...args) => tc.torrentFileSaved(...args)) + ipcRenderer.on('wt-poster', (e, ...args) => tc.torrentPosterSaved(...args)) + ipcRenderer.on('wt-audio-metadata', (e, ...args) => tc.torrentAudioMetadata(...args)) + ipcRenderer.on('wt-server-running', (e, ...args) => tc.torrentServerRunning(...args)) ipcRenderer.on('wt-uncaught-error', (e, err) => telemetry.logUncaughtError('webtorrent', err)) @@ -298,13 +292,6 @@ function backToList () { // If we were already on the torrent list, scroll to the top var contentTag = document.querySelector('.content') if (contentTag) contentTag.scrollTop = 0 - - // Work around virtual-dom issue: it doesn't expose its redraw function, - // and only redraws on requestAnimationFrame(). That means when the user - // closes the window (hide window / minimize to tray) and we want to pause - // the video, we update the vdom but it keeps playing until you reopen! - var mediaTag = document.querySelector('video,audio') - if (mediaTag) mediaTag.pause() }) } @@ -326,177 +313,6 @@ function resumeTorrents () { .forEach((torrentSummary) => controllers.torrentList.startTorrentingSummary(torrentSummary)) } -function isTorrent (file) { - var name = typeof file === 'string' ? file : file.name - var isTorrentFile = path.extname(name).toLowerCase() === '.torrent' - var isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file) - return isTorrentFile || isMagnet -} - -// Gets a torrent summary {name, infoHash, status} from state.saved.torrents -// Returns undefined if we don't know that infoHash -function getTorrentSummary (torrentKey) { - return TorrentSummary.getByKey(state, torrentKey) -} - -function torrentInfoHash (torrentKey, infoHash) { - var torrentSummary = getTorrentSummary(torrentKey) - console.log('got infohash for %s torrent %s', - torrentSummary ? 'existing' : 'new', torrentKey) - - if (!torrentSummary) { - // Check if an existing (non-active) torrent has the same info hash - if (state.saved.torrents.find((t) => t.infoHash === infoHash)) { - ipcRenderer.send('wt-stop-torrenting', infoHash) - return onError(new Error('Cannot add duplicate torrent')) - } - - torrentSummary = { - torrentKey: torrentKey, - status: 'new' - } - state.saved.torrents.unshift(torrentSummary) - sound.play('ADD') - } - - torrentSummary.infoHash = infoHash - update() -} - -function torrentWarning (torrentKey, message) { - onWarning(message) -} - -function torrentError (torrentKey, message) { - // TODO: WebTorrent needs semantic errors - if (message.startsWith('Cannot add duplicate torrent')) { - // Remove infohash from the message - message = 'Cannot add duplicate torrent' - } - onError(message) - - var torrentSummary = getTorrentSummary(torrentKey) - if (torrentSummary) { - console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message) - torrentSummary.status = 'paused' - update() - } -} - -function torrentMetadata (torrentKey, torrentInfo) { - // Summarize torrent - var torrentSummary = getTorrentSummary(torrentKey) - torrentSummary.status = 'downloading' - torrentSummary.name = torrentSummary.displayName || torrentInfo.name - torrentSummary.path = torrentInfo.path - torrentSummary.magnetURI = torrentInfo.magnetURI - // TODO: make torrentInfo immutable, save separately as torrentSummary.info - // For now, check whether torrentSummary.files has already been set: - var hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path - if (!hasDetailedFileInfo) { - torrentSummary.files = torrentInfo.files - } - if (!torrentSummary.selections) { - torrentSummary.selections = torrentSummary.files.map((x) => true) - } - torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files) - update() - - // Save the .torrent file, if it hasn't been saved already - if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey) - - // Auto-generate a poster image, if it hasn't been generated already - if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey) -} - -function torrentDone (torrentKey, torrentInfo) { - // Update the torrent summary - var torrentSummary = getTorrentSummary(torrentKey) - torrentSummary.status = 'seeding' - - // Notify the user that a torrent finished, but only if we actually DL'd at least part of it. - // Don't notify if we merely finished verifying data files that were already on disk. - if (torrentInfo.bytesReceived > 0) { - if (!state.window.isFocused) { - state.dock.badge += 1 - } - showDoneNotification(torrentSummary) - ipcRenderer.send('downloadFinished', getTorrentPath(torrentSummary)) - } - - update() -} - -function torrentProgress (progressInfo) { - // Overall progress across all active torrents, 0 to 1 - var progress = progressInfo.progress - var hasActiveTorrents = progressInfo.hasActiveTorrents - - // Hide progress bar when client has no torrents, or progress is 100% - // TODO: isn't this equivalent to: if (progress === 1) ? - if (!hasActiveTorrents || progress === 1) { - progress = -1 - } - - // Show progress bar under the WebTorrent taskbar icon, on OSX - state.dock.progress = progress - - // Update progress for each individual torrent - progressInfo.torrents.forEach(function (p) { - var torrentSummary = getTorrentSummary(p.torrentKey) - if (!torrentSummary) { - console.log('warning: got progress for missing torrent %s', p.torrentKey) - return - } - torrentSummary.progress = p - }) - - // TODO: Find an efficient way to re-enable this line, which allows subtitle - // files which are completed after a video starts to play to be added - // dynamically to the list of subtitles. - // checkForSubtitles() - - update() -} - -function torrentFileModtimes (torrentKey, fileModtimes) { - var torrentSummary = getTorrentSummary(torrentKey) - torrentSummary.fileModtimes = fileModtimes - State.saveThrottled(state) -} - -function torrentFileSaved (torrentKey, torrentFileName) { - console.log('torrent file saved %s: %s', torrentKey, torrentFileName) - var torrentSummary = getTorrentSummary(torrentKey) - torrentSummary.torrentFileName = torrentFileName - State.saveThrottled(state) -} - -function torrentPosterSaved (torrentKey, posterFileName) { - var torrentSummary = getTorrentSummary(torrentKey) - torrentSummary.posterFileName = posterFileName - State.saveThrottled(state) -} - -function torrentAudioMetadata (infoHash, index, info) { - var torrentSummary = getTorrentSummary(infoHash) - var fileSummary = torrentSummary.files[index] - fileSummary.audioInfo = info - update() -} - -function torrentServerRunning (serverInfo) { - state.server = serverInfo -} - -function getTorrentPath (torrentSummary) { - var itemPath = path.join(torrentSummary.path, torrentSummary.files[0].path) - if (torrentSummary.files.length > 1) { - itemPath = path.dirname(itemPath) - } - return itemPath -} - // Set window dimensions to match video dimensions or fill the screen function setDimensions (dimensions) { // Don't modify the window size if it's already maximized @@ -536,19 +352,6 @@ function setDimensions (dimensions) { state.playing.aspectRatio = aspectRatio } -function showDoneNotification (torrent) { - var notif = new window.Notification('Download Complete', { - body: torrent.name, - silent: true - }) - - notif.onclick = function () { - ipcRenderer.send('show') - } - - sound.play('DONE') -} - // Called when the user adds files (.torrent, files to seed, subtitles) to the app // via any method (drag-drop, drag to app icon, command line) function onOpen (files) { @@ -561,9 +364,9 @@ function onOpen (files) { var subtitles = files.filter(controllers.subtitles.isSubtitle) if (state.location.url() === 'home' || subtitles.length === 0) { - if (files.every(isTorrent)) { + if (files.every(TorrentPlayer.isTorrent)) { if (state.location.url() !== 'home') { - backToList() + dispatch('backToList') } // All .torrent files? Add them. files.forEach((file) => controllers.torrentList.addTorrent(file)) @@ -589,10 +392,6 @@ function onError (err) { update() } -function onWarning (err) { - console.log('warning: %s', err.message || err) -} - function onPaste (e) { if (e.target.tagName.toLowerCase() === 'input') return @@ -624,7 +423,7 @@ function onVisibilityChange () { function onFullscreenChanged (e, isFullScreen) { state.window.isFullScreen = isFullScreen if (!isFullScreen) { - // Aspect ratio gets reset in fullscreen mode, so restore it (OS X) + // Aspect ratio gets reset in fullscreen mode, so restore it (Mac) ipcRenderer.send('setAspectRatio', state.playing.aspectRatio) } diff --git a/src/renderer/views/app.js b/src/renderer/views/app.js new file mode 100644 index 00000000..a32a3006 --- /dev/null +++ b/src/renderer/views/app.js @@ -0,0 +1,97 @@ +const React = require('react') + +const Header = require('./header') + +const Views = { + 'home': require('./torrent-list'), + 'player': require('./player'), + 'create-torrent': require('./create-torrent'), + 'preferences': require('./preferences') +} + +const Modals = { + 'open-torrent-address-modal': require('./open-torrent-address-modal'), + 'remove-torrent-modal': require('./remove-torrent-modal'), + 'update-available-modal': require('./update-available-modal'), + 'unsupported-media-modal': require('./unsupported-media-modal') +} + +module.exports = class App extends React.Component { + + constructor (props) { + super(props) + this.state = props.state + } + + render () { + var state = this.state + + // Hide player controls while playing video, if the mouse stays still for a while + // Never hide the controls when: + // * The mouse is over the controls or we're scrubbing (see CSS) + // * The video is paused + // * The video is playing remotely on Chromecast or Airplay + var hideControls = state.location.url() === 'player' && + state.playing.mouseStationarySince !== 0 && + new Date().getTime() - state.playing.mouseStationarySince > 2000 && + !state.playing.isPaused && + state.playing.location === 'local' && + state.playing.playbackRate === 1 + + var cls = [ + 'view-' + state.location.url(), /* e.g. view-home, view-player */ + 'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */ + ] + if (state.window.isFullScreen) cls.push('is-fullscreen') + if (state.window.isFocused) cls.push('is-focused') + if (hideControls) cls.push('hide-video-controls') + + var vdom = ( +
+
+ {this.getErrorPopover()} +
{this.getView()}
+ {this.getModal()} +
+ ) + + return vdom + } + + getErrorPopover () { + var now = new Date().getTime() + var recentErrors = this.state.errors.filter((x) => now - x.time < 5000) + var hasErrors = recentErrors.length > 0 + + var errorElems = recentErrors.map(function (error, i) { + return (
{error.message}
) + }) + return ( +
+
Error
+ {errorElems} +
+ ) + } + + getModal () { + var state = this.state + if (!state.modal) return + var ModalContents = Modals[state.modal.id] + return ( +
+
+
+ +
+
+ ) + } + + getView () { + var state = this.state + var View = Views[state.location.url()] + return () + } +} diff --git a/src/renderer/views/create-torrent-error-page.js b/src/renderer/views/create-torrent-error-page.js new file mode 100644 index 00000000..b59a360f --- /dev/null +++ b/src/renderer/views/create-torrent-error-page.js @@ -0,0 +1,26 @@ +const React = require('react') + +const {dispatcher} = require('../lib/dispatcher') + +module.exports = class CreateTorrentErrorPage extends React.Component { + render () { + return ( +
+

Create torrent

+

+

+ Sorry, you must select at least one file that is not a hidden file. +

+

+ Hidden files, starting with a . character, are not included. +

+

+

+ +

+
+ ) + } +} diff --git a/src/renderer/views/create-torrent.js b/src/renderer/views/create-torrent.js new file mode 100644 index 00000000..7fe4a308 --- /dev/null +++ b/src/renderer/views/create-torrent.js @@ -0,0 +1,131 @@ +const React = require('react') +const createTorrent = require('create-torrent') +const path = require('path') +const prettyBytes = require('prettier-bytes') + +const {dispatch, dispatcher} = require('../lib/dispatcher') +const CreateTorrentErrorPage = require('./create-torrent-error-page') + +module.exports = class CreateTorrentPage extends React.Component { + render () { + var state = this.props.state + var info = state.location.current() + + // Preprocess: exclude .DS_Store and other dotfiles + var files = info.files + .filter((f) => !f.name.startsWith('.')) + .map((f) => ({name: f.name, path: f.path, size: f.size})) + if (files.length === 0) return () + + // First, extract the base folder that the files are all in + var pathPrefix = info.folderPath + if (!pathPrefix) { + pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix) + if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) { + pathPrefix = path.dirname(pathPrefix) + } + } + + // Sanity check: show the number of files and total size + var numFiles = files.length + var totalBytes = files + .map((f) => f.size) + .reduce((a, b) => a + b, 0) + var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}` + + // 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, basePath + if (files.length === 1) { + // Single file torrent: /a/b/foo.jpg -> torrent name 'foo.jpg', path '/a/b' + defaultName = files[0].name + basePath = pathPrefix + } else { + // Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name 'b', path '/a' + defaultName = path.basename(pathPrefix) + basePath = path.dirname(pathPrefix) + } + var maxFileElems = 100 + var fileElems = files.slice(0, maxFileElems).map(function (file, i) { + var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path) + return (
{relativePath}
) + }) + if (files.length > maxFileElems) { + fileElems.push(
+ {maxFileElems - files.length} more
) + } + var trackers = createTorrent.announceList.join('\n') + var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed' + + return ( +
+

Create torrent {defaultName}

+
+ {torrentInfo} +
+
+ +
{pathPrefix}
+
+
+ {info.showAdvanced ? 'Basic' : 'Advanced'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
{fileElems}
+
+
+
+ + +
+
+ ) + + function handleOK () { + // TODO: dcposch use React refs instead + var announceList = document.querySelector('.torrent-trackers').value + .split('\n') + .map((s) => s.trim()) + .filter((s) => s !== '') + var isPrivate = document.querySelector('.torrent-is-private').checked + var comment = document.querySelector('.torrent-comment').value.trim() + var options = { + // 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. + // If we ever want to add support for that: + // name: document.querySelector('.torrent-name').value + name: defaultName, + path: basePath, + files: files, + announce: announceList, + private: isPrivate, + comment: comment + } + dispatch('createTorrent', options) + } + } +} + +// 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) +} diff --git a/src/renderer/views/header.js b/src/renderer/views/header.js new file mode 100644 index 00000000..22fb83db --- /dev/null +++ b/src/renderer/views/header.js @@ -0,0 +1,50 @@ +const React = require('react') + +const {dispatcher} = require('../lib/dispatcher') + +module.exports = class Header extends React.Component { + render () { + var loc = this.props.state.location + return ( +
+ {this.getTitle()} +
+ + chevron_left + + + chevron_right + +
+
+ {this.getAddButton()} +
+
+ ) + } + + getTitle () { + if (process.platform !== 'darwin') return null + var state = this.props.state + return (
{state.window.title}
) + } + + getAddButton () { + var state = this.props.state + if (state.location.url() !== 'home') return null + return ( + + add + + ) + } +} diff --git a/src/renderer/views/open-torrent-address-modal.js b/src/renderer/views/open-torrent-address-modal.js new file mode 100644 index 00000000..cd106bcb --- /dev/null +++ b/src/renderer/views/open-torrent-address-modal.js @@ -0,0 +1,32 @@ +const React = require('react') + +const {dispatch, dispatcher} = require('../lib/dispatcher') + +module.exports = class OpenTorrentAddressModal extends React.Component { + render () { + // TODO: dcposch remove janky inline + + ) + } +} + +function handleKeyPress (e) { + if (e.which === 13) handleOK() /* hit Enter to submit */ +} + +function handleOK () { + dispatch('exitModal') + // TODO: dcposch use React refs instead + dispatch('addTorrent', document.querySelector('#add-torrent-url').value) +} diff --git a/renderer/views/player.js b/src/renderer/views/player.js similarity index 68% rename from renderer/views/player.js rename to src/renderer/views/player.js index f43dff3d..09f2df51 100644 --- a/renderer/views/player.js +++ b/src/renderer/views/player.js @@ -1,27 +1,28 @@ -module.exports = Player +const React = require('react') +const Bitfield = require('bitfield') +const prettyBytes = require('prettier-bytes') +const zeroFill = require('zero-fill') -var Bitfield = require('bitfield') -var prettyBytes = require('prettier-bytes') -var zeroFill = require('zero-fill') - -var hx = require('../lib/hx') -var TorrentSummary = require('../lib/torrent-summary') -var {dispatch, dispatcher} = require('../lib/dispatcher') +const TorrentSummary = require('../lib/torrent-summary') +const {dispatch, dispatcher} = require('../lib/dispatcher') // Shows a streaming video player. Standard features + Chromecast + Airplay -function Player (state) { - // Show the video as large as will fit in the window, play immediately - // If the video is on Chromecast or Airplay, show a title screen instead - var showVideo = state.playing.location === 'local' - return hx` -
- ${showVideo ? renderMedia(state) : renderCastScreen(state)} - ${renderPlayerControls(state)} +module.exports = class Player extends React.Component { + render () { + // Show the video as large as will fit in the window, play immediately + // If the video is on Chromecast or Airplay, show a title screen instead + var state = this.props.state + var showVideo = state.playing.location === 'local' + return ( +
+ {showVideo ? renderMedia(state) : renderCastScreen(state)} + {renderPlayerControls(state)}
- ` + ) + } } // Handles volume change by wheel @@ -91,42 +92,44 @@ function renderMedia (state) { for (var i = 0; i < state.playing.subtitles.tracks.length; i++) { var track = state.playing.subtitles.tracks[i] var isSelected = state.playing.subtitles.selectedIndex === i - trackTags.push(hx` + trackTags.push( - `) + src={track.buffer} /> + ) } } // Create the