Compare commits

...

51 Commits

Author SHA1 Message Date
DC
229143ffb2 VLC support 2016-04-27 03:27:45 -07:00
DC
3d4d1c8650 Create Torrent: exclude .DS_Store, fix drag-drop 2016-04-27 03:21:14 -07:00
DC
1479369db1 Convert Create Torrent modal to page, clean up App 2016-04-27 02:51:45 -07:00
DC
31ef283e7b Create Torrent dialog 2016-04-27 02:51:45 -07:00
DC
6b70554e63 Center video on current screen (#427)
Fixes #404
2016-04-22 19:59:17 -07:00
grunjol
9a1c329434 detect files with uppercase extensions as playable (#434) 2016-04-21 18:00:15 -03:00
Feross Aboukhadijeh
4aaf6dee05 comment 2016-04-19 23:23:16 -07:00
Feross Aboukhadijeh
86f08ee891 add changelog placeholder 2016-04-19 23:23:13 -07:00
DC
0b85ba9f32 Show an error when adding a dupe torrent
This works around a WebTorrent bug where calling client.add(torrentFilePath) to add a duplicate torrent -- in other words, one whose infoHash we're already torrenting -- creates a new torrent object and later throws an error. Inconsistently, calling client.add(magnetURI) or client.add(infoHash) to add a duplicate torrent returns the existing torrent object that we're already torrenting and doesn't throw an error.

This also fixes a prety nasty bug where pasting a dupe magnet link changed the torrentKey of an existing torrent, breaking the communication between the main and WebTorrent windows

Fixes #364
2016-04-19 20:31:13 -07:00
DC
812ce8724d Show an error when adding an invalid magnet link (#428)
Fixes #386
2016-04-19 20:09:28 -07:00
DC
06f81ff759 Remove extra filesystem dependencies 2016-04-19 06:59:11 -07:00
DC
2693075f9f Keep all torrent files and poster images in app config folder
Fixes #402
2016-04-19 06:59:10 -07:00
DC
c1713810b9 Clean up init 2016-04-19 06:35:28 -07:00
Greenkeeper
e08e5d14a2 chore(package): update electron-packager to version 7.0.0 (#421)
http://greenkeeper.io/
2016-04-18 17:56:05 -07:00
Feross Aboukhadijeh
a3d685e132 OS X: Don't stop music when tabbing to another program (#423) 2016-04-18 17:17:32 -07:00
grunjol
5471760278 srt-to-vtt@1.1.1 (#419) 2016-04-16 09:42:00 -03:00
Feross Aboukhadijeh
969c784df4 Windows Portable App (#417)
* packager: call callbacks consistently

Before this, the callbacks would not being called, which would lead to
an incomplete build on non-OS X platforms when trying to build all for
all platforms.

* packager: Always produce OS X update file regardless of --package option

This makes it consistent with how the windows build always produces the
.nupkg autoupdate files

* packager: fix duplicate npm install

Move "npm prune && npm dedupe" into the release script. Remove an extra
"npm install"

* Make Windows portable app

When a folder named "Portable Settings" exists in same folder as
WebTorrent.exe, then use it instead of the default application config
path.

Closes #358

* packager: remove redundant signing warning

* cross platform zip function

* Set config file path to match config.CONFIG_PATH

* portable app: make electron settings portable

* portable: fix poster/torrent paths

* use cross-zip

* portable app: default download folder inside 'Portable Settings'
2016-04-16 04:18:21 -07:00
DC
85e49dea6d Button styles (#414) 2016-04-15 19:02:38 -07:00
Greenkeeper
a497afe5cf chore(package): update electron-prebuilt to version 0.37.6 (#415)
http://greenkeeper.io/
2016-04-15 17:44:29 -07:00
Feross Aboukhadijeh
2333171de7 Many packager improvements; Windows signing! (#413)
* Many packager improvements; Windows signing!

* Windows signing works now! (Certs are on an external USB stick that
must be plugged into the build machine during build. We can't do the
same for OS X because certs need to exist in the login Keychain to be
found.)

Fixes #219

* Signing is now optional (so OS X and Windows contributors can run
`npm run package` without errors)

* zip, dmg, and deb arguments are now passed in as e.g. "--package=dmg"

* Print a huge warning when signing is disabled so we're less likely to
ship unsigned binaries to users.

* Make console.logs during packaging consistent and parallel
("creating..." followed by "created.")

* More aggressive signing warnings

* Warn when building OS X app on non-OS X platform (because signing
will never work on non-OS X platforms)
* Warn when building Windows app on non-Windows platform (because
signing doesn't work yet on non-Windows platforms)
2016-04-14 22:32:36 -07:00
grunjol
04318d7580 Add multiple subtitles support (#406)
* add multiple subtitles support

* cleanup and remove log
2016-04-14 21:47:50 -07:00
Feross Aboukhadijeh
5e6e5fce1e Remove "Add Fake Airplay/Chromecast" menu items (#411) 2016-04-14 19:42:25 -07:00
Feross Aboukhadijeh
af2ad46958 Only show CC icon for video (#412) 2016-04-14 19:42:13 -07:00
Feross Aboukhadijeh
432d7d4a56 Simplify play/pause handling (#410)
I found it awkward to listen to the video tags 'playing' and 'paused'
events, when we're controlling the state that defines what state it's
in in the first place.

This commit removes those listeners, in favor of just setting things to
the right state immediately when play(), pause(), or playPause() is
called.

Added play(), pause() methods for clarity.
2016-04-14 16:16:54 -07:00
Feross Aboukhadijeh
f93685811a handle case where cb is undefined 2016-04-14 16:06:24 -07:00
Feross Aboukhadijeh
914d07df03 Show error when media format is unsupported (#409)
* fix error about pop

* location-history: add optional callbacks

* set handler on first tick

discovered by @dcposch

* Show error when media format is unsupported

Before this change, the player would just get stuck on the loading
screen forever without notifying the user.
2016-04-14 15:30:26 -07:00
Feross Aboukhadijeh
9c60f104c8 Use winreg 1.1.1 instead of feross fork (#408) 2016-04-14 14:29:44 -07:00
DC
ee7e630177 Block power save (suspend) while casting (#403)
Fixes #397
2016-04-13 11:51:37 -07:00
Feross Aboukhadijeh
ae168ae885 add default torrent: The WIRED CD (#401)
* add default torrent: The WIRED CD

* remove additional unneeded files
2016-04-13 00:24:16 -07:00
DC
ad0fcaed46 Fix two tray icon bugs (#395)
* Stop media on Tray Icon > Hide

* Linux tray support: check for libappindicator1

Fixes #383
2016-04-13 00:23:18 -07:00
Karlo Luis Martinez Martos
304b81908d Windows Volume Mixer fix (#387)
Made a smaller version (32x32) of the .png icon
2016-04-13 00:15:10 -07:00
Feross Aboukhadijeh
b10f8c5bed Fix app.getPath API 2016-04-10 23:10:42 -07:00
Feross Aboukhadijeh
f6b9dbbbc4 Use Electron API to get 'Downloads' folder (#382)
Fixes #359 and #349.
2016-04-10 21:46:24 -07:00
Feross Aboukhadijeh
59cc912378 electron-packager@6 2016-04-10 21:33:12 -07:00
Feross Aboukhadijeh
33663bef3e Linux build: Fix incorrect log output (#381)
Now we use a function closure to capture the `destArch` variable so the
for loop can't change it.
2016-04-10 21:22:34 -07:00
grunjol
e75cd45ec0 packge all linux versions (#379) 2016-04-10 19:54:52 -07:00
Feross Aboukhadijeh
c98f3cd040 Fix JS error on app quit (#377)
This was a rare race condition during app shutdown where a 'wt-'
message would be sent from the hidden webtorrent window to the main
window after the main window was already closed.

Fixes #373
2016-04-10 18:50:00 -07:00
Feross Aboukhadijeh
4c4caba002 Fix text field focus after repeated open (#376)
For #333
2016-04-10 18:34:11 -07:00
Feross Aboukhadijeh
45f6cc5247 Preload sound files for instant playback (#374)
* rm dist at start of build

* renderer style

* preload sound files for instant playback

The first time a sound file is played, the Audio object is cached.

5s after startup, all sound files are automatically preloaded.
2016-04-10 16:46:46 -07:00
DC
69460db294 Exit media when user closes window (#348) 2016-04-10 16:46:34 -07:00
Diego Rodríguez Baquero
f8095fcdbf Use latest webtorrent (#366)
While we have 0.x versions :)
2016-04-10 16:44:11 -07:00
Feross Aboukhadijeh
1a0a2b3658 Add subtitle support (via drag-n-drop) (#361)
* issue template

* cleanup closePlayer() and stopServer()

* Add subtitle support (via drag-n-drop)

Drag and drop a subtitles file (.srt or .vtt) onto the player (or the
app icon on OS X) to add subtitles to the currently playing video.

For #281

* add multiple subtitles structure

* add open subtitle dialog from cc player controls
2016-04-10 16:42:18 -07:00
Alex
f9141dd39c 32 bit build for Linux (#369)
* Add 32 bit arch for Linux

* Fix trailing spaces
2016-04-10 16:38:35 -07:00
Feross Aboukhadijeh
8c2d49f029 Enforce minimimum window size when resizing player (#342)
For audio-only .mov files, which are 0x0.

Closes #340
2016-04-07 21:27:25 -07:00
Evan Miller
da1e120de9 Create error on zero-byte poster (#352)
* Error on zero-byte poster

* return cb to stop execution
2016-04-07 21:25:00 -07:00
Rémi Jouannet
457aca25ee add mute/unmute with the volume icon (#355) 2016-04-07 21:06:28 -07:00
grunjol
ae73ae29c4 add volume icon and slider (#330) 2016-04-07 14:24:23 -03:00
DC
5abf421f11 Auto updater: tell server which platform we're on 2016-04-07 04:35:23 -07:00
Feross Aboukhadijeh
e792532051 CHANGELOG 2016-04-07 03:15:08 -07:00
Feross Aboukhadijeh
5c39665b6a 0.3.3 2016-04-07 03:07:56 -07:00
Feross Aboukhadijeh
d1c4579398 Depend on master electron-packager to fix OS X icon 2016-04-07 03:06:25 -07:00
40 changed files with 1699 additions and 739 deletions

View File

@@ -1,4 +1,4 @@
**What version of WebTorrent Desktop?** **What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu)
**What operating system and version?** **What operating system and version?**

View File

@@ -1,5 +1,22 @@
# WebTorrent Desktop Version History # WebTorrent Desktop Version History
## UNRELEASED
### Added
### Changed
- Use Squirrel.Windows 1.3.0
- Fix installing when the app is already installed
- Don't kill unrelated processes on uninstall
### Fixed
## v0.3.3 - 2016-04-07
### Fixed
- App icon was incorrect (OS X)
## v0.3.2 - 2016-04-07 ## v0.3.2 - 2016-04-07
### Added ### Added
@@ -17,6 +34,7 @@
- Fix installation bugs with .deb file (Linux) - Fix installation bugs with .deb file (Linux)
- Pause audio reliably when closing the window - Pause audio reliably when closing the window
- Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0)
## v0.3.1 - 2016-04-06 ## v0.3.1 - 2016-04-06

View File

@@ -22,7 +22,7 @@
## Screenshot ## Screenshot
<p align="center"> <p align="center">
<img src="./static/screenshot.png" width="562" height="630" alt="screenshot" align="center"> <img src="https://webtorrent.io/img/screenshot-main.png" width="562" height="630" alt="screenshot" align="center">
</p> </p>
## How to Contribute ## How to Contribute
@@ -50,12 +50,23 @@ $ npm run package
To build for one platform: To build for one platform:
``` ```
$ npm run package -- [platform] [package-type] $ npm run package -- [platform]
``` ```
Where `[platform]` is `darwin`, `linux`, or `win32` Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default).
and `[package-type]` is `all` (default), `deb` or `zip` (`linux` platform only) The following optional arguments are available:
- `--sign` - Sign the application (OS X, Windows)
- `--package=[type]` - Package single output type.
- `deb` - Debian package
- `zip` - Linux zip file
- `dmg` - OS X 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.
#### Windows build notes #### Windows build notes

View File

@@ -4,31 +4,60 @@
* Builds app binaries for OS X, Linux, and Windows. * Builds app binaries for OS X, Linux, and Windows.
*/ */
var config = require('../config')
var cp = require('child_process') var cp = require('child_process')
var electronPackager = require('electron-packager') var electronPackager = require('electron-packager')
var fs = require('fs') var fs = require('fs')
var minimist = require('minimist')
var mkdirp = require('mkdirp')
var path = require('path') var path = require('path')
var pkg = require('../package.json')
var rimraf = require('rimraf') var rimraf = require('rimraf')
var series = require('run-series')
var zip = require('cross-zip')
var config = require('../config')
var pkg = require('../package.json')
var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
/*
* Path to folder with the following files:
* - Windows Authenticode private key and cert (authenticode.p12)
* - Windows Authenticode password file (authenticode.txt)
*/
var CERT_PATH = process.platform === 'win32'
? 'D:'
: '/Volumes/Certs'
var DIST_PATH = path.join(config.ROOT_PATH, 'dist')
var argv = minimist(process.argv.slice(2), {
boolean: [
'sign'
],
default: {
package: 'all',
sign: false
},
string: [
'package'
]
})
function build () { function build () {
var platform = process.argv[2] rimraf.sync(DIST_PATH)
var packageType = process.argv.length > 3 ? process.argv[3] : 'all' var platform = argv._[0]
if (platform === 'darwin') { if (platform === 'darwin') {
buildDarwin(printDone) buildDarwin(printDone)
} else if (platform === 'win32') { } else if (platform === 'win32') {
buildWin32(printDone) buildWin32(printDone)
} else if (platform === 'linux') { } else if (platform === 'linux') {
buildLinux(packageType, printDone) buildLinux(printDone)
} else { } else {
buildDarwin(function (err, buildPath) { buildDarwin(function (err) {
printDone(err, buildPath) printDone(err)
buildWin32(function (err, buildPath) { buildWin32(function (err) {
printDone(err, buildPath) printDone(err)
buildLinux(packageType, printDone) buildLinux(printDone)
}) })
}) })
} }
@@ -38,7 +67,8 @@ var all = {
// Build 64 bit binaries only. // Build 64 bit binaries only.
arch: 'x64', arch: 'x64',
// The human-readable copyright line for the app. // The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
// property on Windows, and `NSHumanReadableCopyright` on OS X.
'app-copyright': config.APP_COPYRIGHT, 'app-copyright': config.APP_COPYRIGHT,
// The release version of the application. Maps to the `ProductVersion` metadata // The release version of the application. Maps to the `ProductVersion` metadata
@@ -70,7 +100,7 @@ var all = {
name: config.APP_NAME, name: config.APP_NAME,
// The base directory where the finished package(s) are created. // The base directory where the finished package(s) are created.
out: path.join(config.ROOT_PATH, 'dist'), out: DIST_PATH,
// Replace an already existing output directory. // Replace an already existing output directory.
overwrite: true, overwrite: true,
@@ -131,7 +161,10 @@ var win32 = {
} }
var linux = { var linux = {
platform: 'linux' platform: 'linux',
// Build 32/64 bit binaries.
arch: 'all'
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option. // Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
} }
@@ -141,8 +174,10 @@ build()
function buildDarwin (cb) { function buildDarwin (cb) {
var plist = require('plist') var plist = require('plist')
console.log('OS X: Packaging electron...')
electronPackager(Object.assign({}, all, darwin), function (err, buildPath) { electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
if (err) return cb(err) if (err) return cb(err)
console.log('OS X: Packaged electron. ' + buildPath[0])
var appPath = path.join(buildPath[0], config.APP_NAME + '.app') var appPath = path.join(buildPath[0], config.APP_NAME + '.app')
var contentsPath = path.join(appPath, 'Contents') var contentsPath = path.join(appPath, 'Contents')
@@ -185,11 +220,24 @@ function buildDarwin (cb) {
cp.execSync(`cp ${config.APP_FILE_ICON + '.icns'} ${resourcesPath}`) cp.execSync(`cp ${config.APP_FILE_ICON + '.icns'} ${resourcesPath}`)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
var appDmg = require('appdmg') if (argv.sign) {
signApp(function (err) {
if (err) return cb(err)
pack(cb)
})
} else {
printWarning()
pack(cb)
}
} else {
printWarning()
}
function signApp (cb) {
var sign = require('electron-osx-sign') var sign = require('electron-osx-sign')
/* /*
* Sign the app with Apple Developer ID certificate. We sign the app for 2 reasons: * Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons:
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by * - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
* the same author as the current version. * the same author as the current version.
* - So users will not a see a warning about the app coming from an "Unidentified * - So users will not a see a warning about the app coming from an "Unidentified
@@ -207,48 +255,71 @@ function buildDarwin (cb) {
verbose: true verbose: true
} }
console.log('OS X: Signing app...')
sign(signOpts, function (err) { sign(signOpts, function (err) {
if (err) return cb(err) if (err) return cb(err)
console.log('OS X: Signed app.')
cb(null)
})
}
// Create .zip file (used by the auto-updater) function pack (cb) {
var zipPath = path.join(config.ROOT_PATH, 'dist', BUILD_NAME + '-darwin.zip') packageZip() // always produce .zip file, used for automatic updates
cp.execSync(`cd ${buildPath[0]} && zip -r -y ${zipPath} ${config.APP_NAME + '.app'}`)
console.log('Created OS X .zip file.')
var targetPath = path.join(config.ROOT_PATH, 'dist', BUILD_NAME + '.dmg') if (argv.package === 'dmg' || argv.package === 'all') {
rimraf.sync(targetPath) packageDmg(cb)
}
}
// Create a .dmg (OS X disk image) file, for easy user installation. function packageZip () {
var dmgOpts = { // Create .zip file (used by the auto-updater)
basepath: config.ROOT_PATH, console.log('OS X: Creating zip...')
target: targetPath,
specification: { var inPath = path.join(buildPath[0], config.APP_NAME + '.app')
title: config.APP_NAME, var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
icon: config.APP_ICON + '.icns', zip(inPath, outPath)
background: path.join(config.STATIC_PATH, 'appdmg.png'),
'icon-size': 128, console.log('OS X: Created zip.')
contents: [ }
{ x: 122, y: 240, type: 'file', path: appPath },
{ x: 380, y: 240, type: 'link', path: '/Applications' }, function packageDmg (cb) {
// Hide hidden icons out of view, for users who have hidden files shown. console.log('OS X: Creating dmg...')
// https://github.com/LinusU/node-appdmg/issues/45#issuecomment-153924954
{ x: 50, y: 500, type: 'position', path: '.background' }, var appDmg = require('appdmg')
{ x: 100, y: 500, type: 'position', path: '.DS_Store' },
{ x: 150, y: 500, type: 'position', path: '.Trashes' }, var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
{ x: 200, y: 500, type: 'position', path: '.VolumeIcon.icns' } rimraf.sync(targetPath)
]
} // Create a .dmg (OS X disk image) file, for easy user installation.
var dmgOpts = {
basepath: config.ROOT_PATH,
target: targetPath,
specification: {
title: config.APP_NAME,
icon: config.APP_ICON + '.icns',
background: path.join(config.STATIC_PATH, 'appdmg.png'),
'icon-size': 128,
contents: [
{ x: 122, y: 240, type: 'file', path: appPath },
{ x: 380, y: 240, type: 'link', path: '/Applications' },
// Hide hidden icons out of view, for users who have hidden files shown.
// https://github.com/LinusU/node-appdmg/issues/45#issuecomment-153924954
{ x: 50, y: 500, type: 'position', path: '.background' },
{ x: 100, y: 500, type: 'position', path: '.DS_Store' },
{ x: 150, y: 500, type: 'position', path: '.Trashes' },
{ x: 200, y: 500, type: 'position', path: '.VolumeIcon.icns' }
]
} }
}
var dmg = appDmg(dmgOpts) var dmg = appDmg(dmgOpts)
dmg.on('error', cb) dmg.on('error', cb)
dmg.on('progress', function (info) { dmg.on('progress', function (info) {
if (info.type === 'step-begin') console.log(info.title + '...') if (info.type === 'step-begin') console.log(info.title + '...')
}) })
dmg.on('finish', function (info) { dmg.on('finish', function (info) {
console.log('Created OS X disk image (.dmg) file.') console.log('OS X: Created dmg.')
cb(null, buildPath) cb(null)
})
}) })
} }
}) })
@@ -257,80 +328,148 @@ function buildDarwin (cb) {
function buildWin32 (cb) { function buildWin32 (cb) {
var installer = require('electron-winstaller') var installer = require('electron-winstaller')
console.log('Windows: Packaging electron...')
electronPackager(Object.assign({}, all, win32), function (err, buildPath) { electronPackager(Object.assign({}, all, win32), function (err, buildPath) {
if (err) return cb(err) if (err) return cb(err)
console.log('Windows: Packaged electron. ' + buildPath[0])
console.log('Creating Windows installer...') var signWithParams
installer.createWindowsInstaller({ if (process.platform === 'win32') {
appDirectory: buildPath[0], if (argv.sign) {
authors: config.APP_TEAM, var certificateFile = path.join(CERT_PATH, 'authenticode.p12')
// certificateFile: '', // TODO var certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
description: config.APP_NAME, var timestampServer = 'http://timestamp.comodoca.com'
exe: config.APP_NAME + '.exe', signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
iconUrl: config.GITHUB_URL_RAW + '/static/' + config.APP_NAME + '.ico', } else {
loadingGif: path.join(config.STATIC_PATH, 'loading.gif'), printWarning()
remoteReleases: config.GITHUB_URL, }
name: config.APP_NAME, } else {
noMsi: true, printWarning()
outputDirectory: path.join(config.ROOT_PATH, 'dist'), }
productName: config.APP_NAME,
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe', var tasks = []
setupIcon: config.APP_ICON + '.ico', if (argv.package === 'exe' || argv.package === 'all') {
title: config.APP_NAME, tasks.push((cb) => packageInstaller(cb))
usePackageJson: false, }
version: pkg.version if (argv.package === 'portable' || argv.package === 'all') {
}).then(function () { tasks.push((cb) => packagePortable(cb))
console.log('Created Windows installer.') }
cb(null, buildPath) series(tasks, cb)
}).catch(cb)
function packageInstaller (cb) {
console.log('Windows: Creating installer...')
installer.createWindowsInstaller({
appDirectory: buildPath[0],
authors: config.APP_TEAM,
description: config.APP_NAME,
exe: config.APP_NAME + '.exe',
iconUrl: config.GITHUB_URL_RAW + '/static/' + config.APP_NAME + '.ico',
loadingGif: path.join(config.STATIC_PATH, 'loading.gif'),
name: config.APP_NAME,
noMsi: true,
outputDirectory: DIST_PATH,
productName: config.APP_NAME,
remoteReleases: config.GITHUB_URL,
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe',
setupIcon: config.APP_ICON + '.ico',
signWithParams: signWithParams,
title: config.APP_NAME,
usePackageJson: false,
version: pkg.version
}).then(function () {
console.log('Windows: Created installer.')
cb(null)
}).catch(cb)
}
function packagePortable (cb) {
// Create Windows portable app
console.log('Windows: Creating portable app...')
var portablePath = path.join(buildPath[0], 'Portable Settings')
mkdirp.sync(portablePath)
var inPath = path.join(DIST_PATH, path.basename(buildPath[0]))
var outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip')
zip(inPath, outPath)
console.log('Windows: Created portable app.')
cb(null)
}
}) })
} }
function buildLinux (packageType, cb) { function buildLinux (cb) {
console.log('Linux: Packaging electron...')
electronPackager(Object.assign({}, all, linux), function (err, buildPath) { electronPackager(Object.assign({}, all, linux), function (err, buildPath) {
if (err) return cb(err) if (err) return cb(err)
console.log('Linux: Packaged electron. ' + buildPath[0])
var distPath = path.join(config.ROOT_PATH, 'dist') var tasks = []
var filesPath = buildPath[0] buildPath.forEach(function (filesPath) {
var destArch = filesPath.split('-').pop()
if (packageType === 'deb' || packageType === 'all') { if (argv.package === 'deb' || argv.package === 'all') {
// Create .deb file for debian based platforms tasks.push((cb) => packageDeb(filesPath, destArch, cb))
var deb = require('nobin-debian-installer')() }
var destPath = path.join('/opt', pkg.name) if (argv.package === 'zip' || argv.package === 'all') {
tasks.push((cb) => packageZip(filesPath, destArch, cb))
deb.pack({ }
package: pkg, })
info: { series(tasks, cb)
arch: 'amd64',
targetDir: distPath,
depends: 'libc6 (>= 2.4)',
scripts: {
postinst: path.join(config.STATIC_PATH, 'linux', 'postinst'),
prerm: path.join(config.STATIC_PATH, 'linux', 'prerm')
}
}
}, [{
src: ['./**'],
dest: destPath,
expand: true,
cwd: filesPath
}], function (err, done) {
if (err) return console.error(err.message || err)
console.log('Created Linux .deb file.')
})
}
if (packageType === 'zip' || packageType === 'all') {
// Create .zip file for Linux
var zipPath = path.join(config.ROOT_PATH, 'dist', BUILD_NAME + '-linux.zip')
var appFolderName = path.basename(filesPath)
cp.execSync(`cd ${distPath} && zip -r -y ${zipPath} ${appFolderName}`)
console.log('Created Linux .zip file.')
}
}) })
function packageDeb (filesPath, destArch, cb) {
// Create .deb file for Debian-based platforms
console.log(`Linux: Creating ${destArch} deb...`)
var deb = require('nobin-debian-installer')()
var destPath = path.join('/opt', pkg.name)
deb.pack({
package: pkg,
info: {
arch: destArch === 'x64' ? 'amd64' : 'i386',
targetDir: DIST_PATH,
depends: 'libc6 (>= 2.4)',
scripts: {
postinst: path.join(config.STATIC_PATH, 'linux', 'postinst'),
prerm: path.join(config.STATIC_PATH, 'linux', 'prerm')
}
}
}, [{
src: ['./**'],
dest: destPath,
expand: true,
cwd: filesPath
}], function (err) {
if (err) return cb(err)
console.log(`Linux: Created ${destArch} deb.`)
cb(null)
})
}
function packageZip (filesPath, destArch, cb) {
// Create .zip file for Linux
console.log(`Linux: Creating ${destArch} zip...`)
var inPath = path.join(DIST_PATH, path.basename(filesPath))
var outPath = path.join(DIST_PATH, BUILD_NAME + '-linux-' + destArch + '.zip')
zip(inPath, outPath)
console.log(`Linux: Created ${destArch} zip.`)
cb(null)
}
} }
function printDone (err, buildPath) { function printDone (err) {
if (err) console.error(err.message || err) if (err) console.error(err.message || err)
else console.log('Built ' + buildPath[0]) }
/*
* Print a large warning when signing is disabled so we are less likely to accidentally
* ship unsigned binaries to users.
*/
function printWarning () {
console.log(fs.readFileSync(path.join(__dirname, 'warning.txt'), 'utf8'))
} }

View File

@@ -2,7 +2,7 @@
set -e set -e
git diff --exit-code git diff --exit-code
npm run package npm run package -- --sign
git push git push
git push --tags git push --tags
npm publish npm publish

View File

@@ -6,4 +6,6 @@ npm run update-authors
git diff --exit-code git diff --exit-code
rm -rf node_modules/ rm -rf node_modules/
npm install npm install
npm prune
npm dedupe
npm test npm test

12
bin/warning.txt Normal file
View File

@@ -0,0 +1,12 @@
*********************************************************
_ _ ___ ______ _ _ _____ _ _ _____
| | | |/ _ \ | ___ \ \ | |_ _| \ | | __ \
| | | / /_\ \| |_/ / \| | | | | \| | | \/
| |/\| | _ || /| . ` | | | | . ` | | __
\ /\ / | | || |\ \| |\ |_| |_| |\ | |_\ \
\/ \/\_| |_/\_| \_\_| \_/\___/\_| \_/\____/
Application is NOT signed. Do not ship this to users!
*********************************************************

View File

@@ -1,10 +1,13 @@
var applicationConfigPath = require('application-config-path') var appConfig = require('application-config')('WebTorrent')
var path = require('path') var path = require('path')
var pathExists = require('path-exists')
var APP_NAME = 'WebTorrent' var APP_NAME = 'WebTorrent'
var APP_TEAM = 'The WebTorrent Project' var APP_TEAM = 'The WebTorrent Project'
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')
module.exports = { module.exports = {
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM, APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'), APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'),
@@ -14,59 +17,43 @@ module.exports = {
APP_VERSION: APP_VERSION, APP_VERSION: APP_VERSION,
APP_WINDOW_TITLE: APP_NAME + ' (BETA)', APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update?version=' + APP_VERSION,
AUTO_UPDATE_CHECK_STARTUP_DELAY: 5 * 1000 /* 5 seconds */, AUTO_UPDATE_CHECK_STARTUP_DELAY: 5 * 1000 /* 5 seconds */,
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update' +
'?version=' + APP_VERSION + '&platform=' + process.platform,
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report', CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
CONFIG_PATH: applicationConfigPath(APP_NAME), CONFIG_PATH: getConfigPath(),
CONFIG_POSTER_PATH: path.join(applicationConfigPath(APP_NAME), 'Posters'), CONFIG_POSTER_PATH: path.join(getConfigPath(), 'Posters'),
CONFIG_TORRENT_PATH: path.join(applicationConfigPath(APP_NAME), 'Torrents'), CONFIG_TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop', GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master', GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
IS_PORTABLE: isPortable(),
IS_PRODUCTION: isProduction(), IS_PRODUCTION: isProduction(),
ROOT_PATH: __dirname, ROOT_PATH: __dirname,
STATIC_PATH: path.join(__dirname, 'static'), STATIC_PATH: path.join(__dirname, 'static'),
SOUND_ADD: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'add.wav'),
volume: 0.2
},
SOUND_DELETE: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'delete.wav'),
volume: 0.1
},
SOUND_DISABLE: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'disable.wav'),
volume: 0.2
},
SOUND_DONE: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'done.wav'),
volume: 0.2
},
SOUND_ENABLE: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'enable.wav'),
volume: 0.2
},
SOUND_ERROR: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'error.wav'),
volume: 0.2
},
SOUND_PLAY: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'play.wav'),
volume: 0.2
},
SOUND_STARTUP: {
url: 'file://' + path.join(__dirname, 'static', 'sound', 'startup.wav'),
volume: 0.4
},
WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'), 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_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'),
WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html')
WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents
WINDOW_MIN_WIDTH: 425
}
function getConfigPath () {
if (isPortable()) {
return PORTABLE_PATH
} else {
return path.dirname(appConfig.filePath)
}
}
function isPortable () {
return process.platform === 'win32' && isProduction() && pathExists(PORTABLE_PATH)
} }
function isProduction () { function isProduction () {

View File

@@ -194,17 +194,17 @@ function uninstallWin32 () {
}) })
commandKey.get('', function (err, item) { commandKey.get('', function (err, item) {
if (!err && item.value.indexOf(command) >= 0) { if (!err && item.value.indexOf(command) >= 0) {
eraseProtocol() destroyProtocol()
} }
}) })
} }
function eraseProtocol () { function destroyProtocol () {
var protocolKey = new Registry({ var protocolKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + protocol key: '\\Software\\Classes\\' + protocol
}) })
protocolKey.erase(function () {}) protocolKey.destroy(function () {})
} }
} }
@@ -216,7 +216,7 @@ function uninstallWin32 () {
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + id key: '\\Software\\Classes\\' + id
}) })
idKey.erase(getExt) idKey.destroy(getExt)
} }
function getExt () { function getExt () {
@@ -226,24 +226,23 @@ function uninstallWin32 () {
}) })
extKey.get('', function (err, item) { extKey.get('', function (err, item) {
if (!err && item.value === id) { if (!err && item.value === id) {
eraseExt() destroyExt()
} }
}) })
} }
function eraseExt () { function destroyExt () {
var extKey = new Registry({ var extKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + ext key: '\\Software\\Classes\\' + ext
}) })
extKey.erase(function () {}) extKey.destroy(function () {})
} }
} }
} }
function installLinux () { function installLinux () {
var fs = require('fs') var fs = require('fs-extra')
var mkdirp = require('mkdirp')
var os = require('os') var os = require('os')
var path = require('path') var path = require('path')
@@ -277,7 +276,7 @@ function installLinux () {
'applications', 'applications',
'webtorrent-desktop.desktop' 'webtorrent-desktop.desktop'
) )
mkdirp(path.dirname(desktopFilePath)) fs.mkdirp(path.dirname(desktopFilePath))
fs.writeFile(desktopFilePath, desktopFile, function (err) { fs.writeFile(desktopFilePath, desktopFile, function (err) {
if (err) return log.error(err.message) if (err) return log.error(err.message)
}) })
@@ -298,7 +297,7 @@ function installLinux () {
'icons', 'icons',
'webtorrent-desktop.png' 'webtorrent-desktop.png'
) )
mkdirp(path.dirname(iconFilePath)) fs.mkdirp(path.dirname(iconFilePath))
fs.writeFile(iconFilePath, iconFile, function (err) { fs.writeFile(iconFilePath, iconFile, function (err) {
if (err) return log.error(err.message) if (err) return log.error(err.message)
}) })
@@ -308,7 +307,7 @@ function installLinux () {
function uninstallLinux () { function uninstallLinux () {
var os = require('os') var os = require('os')
var path = require('path') var path = require('path')
var rimraf = require('rimraf') var fs = require('fs-extra')
var desktopFilePath = path.join( var desktopFilePath = path.join(
os.homedir(), os.homedir(),
@@ -317,7 +316,7 @@ function uninstallLinux () {
'applications', 'applications',
'webtorrent-desktop.desktop' 'webtorrent-desktop.desktop'
) )
rimraf.sync(desktopFilePath) fs.removeSync(desktopFilePath)
var iconFilePath = path.join( var iconFilePath = path.join(
os.homedir(), os.homedir(),
@@ -326,5 +325,5 @@ function uninstallLinux () {
'icons', 'icons',
'webtorrent-desktop.png' 'webtorrent-desktop.png'
) )
rimraf.sync(iconFilePath) fs.removeSync(iconFilePath)
} }

View File

@@ -37,6 +37,10 @@ if (!shouldQuit) {
} }
function init () { function init () {
if (config.IS_PORTABLE) {
app.setPath('userData', config.CONFIG_PATH)
}
app.ipcReady = false // main window has finished loading and IPC is ready app.ipcReady = false // main window has finished loading and IPC is ready
app.isQuitting = false app.isQuitting = false
@@ -116,7 +120,7 @@ function sliceArgv (argv) {
function processArgv (argv) { function processArgv (argv) {
argv.forEach(function (arg) { argv.forEach(function (arg) {
if (arg === '-n') { if (arg === '-n') {
windows.main.send('dispatch', 'showCreateTorrent') windows.main.send('dispatch', 'showOpenSeedFiles')
} else if (arg === '-o') { } else if (arg === '-o') {
windows.main.send('dispatch', 'showOpenTorrentFile') windows.main.send('dispatch', 'showOpenTorrentFile')
} else if (arg === '-u') { } else if (arg === '-u') {

View File

@@ -36,7 +36,7 @@ function init () {
}) })
ipcMain.on('showOpenTorrentFile', menu.showOpenTorrentFile) ipcMain.on('showOpenTorrentFile', menu.showOpenTorrentFile)
ipcMain.on('showCreateTorrent', menu.showCreateTorrent) ipcMain.on('showOpenSeedFiles', menu.showOpenSeedFiles)
ipcMain.on('setBounds', function (e, bounds, maximize) { ipcMain.on('setBounds', function (e, bounds, maximize) {
setBounds(bounds, maximize) setBounds(bounds, maximize)
@@ -87,7 +87,7 @@ function init () {
var oldEmit = ipcMain.emit var oldEmit = ipcMain.emit
ipcMain.emit = function (name, e, ...args) { ipcMain.emit = function (name, e, ...args) {
// Relay messages between the main window and the WebTorrent hidden window // Relay messages between the main window and the WebTorrent hidden window
if (name.startsWith('wt-')) { if (name.startsWith('wt-') && !app.isQuitting) {
if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') { if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
// Send message to main window // Send message to main window
windows.main.send(name, ...args) windows.main.send(name, ...args)
@@ -140,6 +140,14 @@ function setBounds (bounds, maximize) {
// Assuming we're not maximized or maximizing, set the window size // Assuming we're not maximized or maximizing, set the window size
if (!willBeMaximized) { if (!willBeMaximized) {
log('setBounds: setting bounds to ' + JSON.stringify(bounds)) log('setBounds: setting bounds to ' + JSON.stringify(bounds))
if (bounds.x === null && bounds.y === null) {
// X and Y not specified? By default, center on current screen
var screen = require('screen')
var scr = screen.getDisplayMatching(windows.main.getBounds())
bounds.x = Math.round(scr.bounds.x + scr.bounds.width / 2 - bounds.width / 2)
bounds.y = Math.round(scr.bounds.y + scr.bounds.height / 2 - bounds.height / 2)
log('setBounds: centered to ' + JSON.stringify(bounds))
}
windows.main.setBounds(bounds, true) windows.main.setBounds(bounds, true)
} else { } else {
log('setBounds: not setting bounds because of window maximization') log('setBounds: not setting bounds because of window maximization')

View File

@@ -5,7 +5,7 @@ module.exports = {
onWindowShow, onWindowShow,
onPlayerOpen, onPlayerOpen,
onPlayerClose, onPlayerClose,
showCreateTorrent, showOpenSeedFiles,
showOpenTorrentFile, showOpenTorrentFile,
toggleFullScreen toggleFullScreen
} }
@@ -70,11 +70,6 @@ function showWebTorrentWindow () {
windows.webtorrent.webContents.openDevTools({ detach: true }) windows.webtorrent.webContents.openDevTools({ detach: true })
} }
function addFakeDevice (device) {
log('addFakeDevice %s', device)
windows.main.send('addFakeDevice', device)
}
function onWindowShow () { function onWindowShow () {
log('onWindowShow') log('onWindowShow')
getMenuItem('Full Screen').enabled = true getMenuItem('Full Screen').enabled = true
@@ -114,7 +109,7 @@ function getMenuItem (label) {
} }
// Prompts the user for a file or folder, then makes a torrent out of the data // Prompts the user for a file or folder, then makes a torrent out of the data
function showCreateTorrent () { function showOpenSeedFiles () {
// Allow only a single selection // Allow only a single selection
// To create a multi-file torrent, the user must select a folder // To create a multi-file torrent, the user must select a folder
electron.dialog.showOpenDialog({ electron.dialog.showOpenDialog({
@@ -122,10 +117,8 @@ function showCreateTorrent () {
properties: [ 'openFile', 'openDirectory' ] properties: [ 'openFile', 'openDirectory' ]
}, function (filenames) { }, function (filenames) {
if (!Array.isArray(filenames)) return if (!Array.isArray(filenames)) return
var options = { var fileOrFolder = filenames[0]
files: filenames[0] windows.main.send('dispatch', 'showCreateTorrent', fileOrFolder)
}
windows.main.send('dispatch', 'createTorrent', options)
}) })
} }
@@ -153,7 +146,7 @@ function getAppMenuTemplate () {
{ {
label: 'Create New Torrent...', label: 'Create New Torrent...',
accelerator: 'CmdOrCtrl+N', accelerator: 'CmdOrCtrl+N',
click: showCreateTorrent click: showOpenSeedFiles
}, },
{ {
label: 'Open Torrent File...', label: 'Open Torrent File...',
@@ -263,17 +256,6 @@ function getAppMenuTemplate () {
? 'Alt+Command+P' ? 'Alt+Command+P'
: 'Ctrl+Shift+P', : 'Ctrl+Shift+P',
click: showWebTorrentWindow click: showWebTorrentWindow
},
{
type: 'separator'
},
{
label: 'Add Fake Airplay',
click: () => addFakeDevice('airplay')
},
{
label: 'Add Fake Chromecast',
click: () => addFakeDevice('chromecast')
} }
] ]
} }
@@ -389,7 +371,7 @@ function getDockMenuTemplate () {
{ {
label: 'Create New Torrent...', label: 'Create New Torrent...',
accelerator: 'CmdOrCtrl+N', accelerator: 'CmdOrCtrl+N',
click: showCreateTorrent click: showOpenSeedFiles
}, },
{ {
label: 'Open Torrent File...', label: 'Open Torrent File...',

View File

@@ -7,7 +7,6 @@ var electron = require('electron')
var fs = require('fs') var fs = require('fs')
var os = require('os') var os = require('os')
var path = require('path') var path = require('path')
var pathExists = require('path-exists')
var app = electron.app var app = electron.app
@@ -118,7 +117,8 @@ function updateShortcuts (cb) {
var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk') var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk')
// Check if the desktop shortcut has been previously deleted and and keep it deleted // Check if the desktop shortcut has been previously deleted and and keep it deleted
// if it was // if it was
pathExists(desktopShortcutPath).then(function (desktopShortcutExists) { fs.access(desktopShortcutPath, function (err) {
var desktopShortcutExists = !err
createShortcuts(function () { createShortcuts(function () {
if (desktopShortcutExists) { if (desktopShortcutExists) {
cb() cb()

View File

@@ -1,7 +1,9 @@
module.exports = { module.exports = {
init init,
hasTray
} }
var cp = require('child_process')
var path = require('path') var path = require('path')
var electron = require('electron') var electron = require('electron')
@@ -17,6 +19,22 @@ function init () {
// OS X has no tray icon // OS X has no tray icon
if (process.platform === 'darwin') return if (process.platform === 'darwin') return
// On Linux, asynchronously check for libappindicator1
if (process.platform === 'linux') {
checkLinuxTraySupport(function (supportsTray) {
if (supportsTray) createTrayIcon()
})
}
// Windows always supports minimize-to-tray
if (process.platform === 'win32') createTrayIcon()
}
function hasTray () {
return !!trayIcon
}
function createTrayIcon () {
trayIcon = new Tray(path.join(__dirname, '..', 'static', 'WebTorrentSmall.png')) trayIcon = new Tray(path.join(__dirname, '..', 'static', 'WebTorrentSmall.png'))
// On Windows, left click to open the app, right click for context menu // On Windows, left click to open the app, right click for context menu
@@ -29,6 +47,18 @@ function init () {
windows.main.on('hide', updateTrayMenu) windows.main.on('hide', updateTrayMenu)
} }
function checkLinuxTraySupport (cb) {
// Check that we're on Ubuntu (or another debian system) and that we have
// libappindicator1. If WebTorrent was installed from the deb file, we should
// always have it. If it was installed from the zip file, we might not.
cp.exec('dpkg --get-selections libappindicator1', function (err, stdout) {
if (err) return cb(false)
// Unfortunately there's no cleaner way, as far as I can tell, to check
// whether a debian package is installed:
cb(stdout.endsWith('\tinstall\n'))
})
}
function updateTrayMenu () { function updateTrayMenu () {
var showHideMenuItem var showHideMenuItem
if (windows.main.isVisible()) { if (windows.main.isVisible()) {
@@ -49,4 +79,5 @@ function showApp () {
function hideApp () { function hideApp () {
windows.main.hide() windows.main.hide()
windows.main.send('dispatch', 'backToList')
} }

View File

@@ -11,6 +11,7 @@ var electron = require('electron')
var config = require('../config') var config = require('../config')
var menu = require('./menu') var menu = require('./menu')
var tray = require('./tray')
function createAboutWindow () { function createAboutWindow () {
if (windows.about) { if (windows.about) {
@@ -72,6 +73,10 @@ function createWebTorrentHiddenWindow () {
win.hide() win.hide()
} }
}) })
win.once('closed', function () {
windows.webtorrent = null
})
} }
function createMainWindow () { function createMainWindow () {
@@ -81,9 +86,9 @@ function createMainWindow () {
var win = windows.main = new electron.BrowserWindow({ var win = windows.main = new electron.BrowserWindow({
backgroundColor: '#1E1E1E', backgroundColor: '#1E1E1E',
darkTheme: true, // Forces dark theme (GTK+3) darkTheme: true, // Forces dark theme (GTK+3)
icon: config.APP_ICON + '.png', icon: config.APP_ICON + 'Smaller.png', // Window and Volume Mixer icon.
minWidth: 425, minWidth: config.WINDOW_MIN_WIDTH,
minHeight: 38 + (120 * 2), // header height + 2 torrents minHeight: config.WINDOW_MIN_HEIGHT,
show: false, // Hide window until DOM finishes loading show: false, // Hide window until DOM finishes loading
title: config.APP_WINDOW_TITLE, title: config.APP_WINDOW_TITLE,
titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X) titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X)
@@ -104,10 +109,12 @@ function createMainWindow () {
win.on('leave-full-screen', () => menu.onToggleFullScreen(false)) win.on('leave-full-screen', () => menu.onToggleFullScreen(false))
win.on('close', function (e) { win.on('close', function (e) {
if (!electron.app.isQuitting) { if (process.platform !== 'darwin' && !tray.hasTray()) {
electron.app.quit()
} else if (!electron.app.isQuitting) {
e.preventDefault() e.preventDefault()
win.send('dispatch', 'pause')
win.hide() win.hide()
win.send('dispatch', 'backToList')
} }
}) })

View File

@@ -1,7 +1,7 @@
{ {
"name": "webtorrent-desktop", "name": "webtorrent-desktop",
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.", "description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
"version": "0.3.2", "version": "0.3.3",
"author": { "author": {
"name": "Feross Aboukhadijeh", "name": "Feross Aboukhadijeh",
"email": "feross@feross.org", "email": "feross@feross.org",
@@ -15,37 +15,42 @@
}, },
"dependencies": { "dependencies": {
"airplay-js": "guerrerocarlos/node-airplay-js", "airplay-js": "guerrerocarlos/node-airplay-js",
"application-config": "^0.2.0", "application-config": "feross/node-application-config",
"application-config-path": "^0.1.0",
"bitfield": "^1.0.2", "bitfield": "^1.0.2",
"chromecasts": "^1.8.0", "chromecasts": "^1.8.0",
"create-torrent": "^3.22.1", "concat-stream": "^1.5.1",
"create-torrent": "^3.24.5",
"deep-equal": "^1.0.1", "deep-equal": "^1.0.1",
"dlnacasts": "^0.0.3", "dlnacasts": "^0.0.3",
"drag-drop": "^2.11.0", "drag-drop": "^2.11.0",
"electron-localshortcut": "^0.6.0", "electron-localshortcut": "^0.6.0",
"electron-prebuilt": "0.37.5", "electron-prebuilt": "0.37.6",
"fs-extra": "^0.27.0",
"hyperx": "^2.0.2", "hyperx": "^2.0.2",
"languagedetect": "^1.1.1",
"main-loop": "^3.2.0", "main-loop": "^3.2.0",
"mkdirp": "^0.5.1",
"musicmetadata": "^2.0.2", "musicmetadata": "^2.0.2",
"network-address": "^1.1.0", "network-address": "^1.1.0",
"path-exists": "^2.1.0",
"prettier-bytes": "^1.0.1", "prettier-bytes": "^1.0.1",
"rimraf": "^2.5.2",
"simple-get": "^2.0.0", "simple-get": "^2.0.0",
"srt-to-vtt": "^1.1.1",
"upload-element": "^1.0.1", "upload-element": "^1.0.1",
"virtual-dom": "^2.1.1", "virtual-dom": "^2.1.1",
"webtorrent": "^0.90.0", "wcjs-player": "^0.5.7",
"winreg": "feross/node-winreg" "webchimera.js": "^0.2.3",
"webtorrent": "0.x",
"winreg": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"cross-zip": "^1.0.0",
"electron-osx-sign": "^0.3.0", "electron-osx-sign": "^0.3.0",
"electron-packager": "^6.0.0", "electron-packager": "^7.0.0",
"electron-winstaller": "feross/windows-installer#build", "electron-winstaller": "feross/windows-installer#build",
"gh-release": "^2.0.3", "gh-release": "^2.0.3",
"nobin-debian-installer": "^0.0.8", "minimist": "^1.2.0",
"nobin-debian-installer": "^0.0.9",
"plist": "^1.2.0", "plist": "^1.2.0",
"run-series": "^1.1.4",
"standard": "^6.0.5" "standard": "^6.0.5"
}, },
"homepage": "https://webtorrent.io", "homepage": "https://webtorrent.io",
@@ -67,10 +72,13 @@
}, },
"scripts": { "scripts": {
"clean": "node ./bin/clean.js", "clean": "node ./bin/clean.js",
"package": "npm install && npm prune && npm dedupe && node ./bin/package.js", "package": "node ./bin/package.js",
"size": "npm run package -- darwin && du -ch dist/WebTorrent-darwin-x64 | grep total",
"start": "electron .", "start": "electron .",
"test": "standard", "test": "standard",
"update-authors": "./bin/update-authors.sh" "update-authors": "./bin/update-authors.sh"
},
"cmake-js": {
"runtime": "electron",
"runtimeVersion": "0.37.5"
} }
} }

View File

@@ -113,28 +113,32 @@ table {
opacity: 0.3; opacity: 0.3;
} }
/* .float-right {
* BUTTONS float: right;
*/
a,
i {
cursor: default;
-webkit-app-region: no-drag;
} }
a:not(.disabled):hover, .expand-collapse {
i:not(.disabled):hover { cursor: pointer;
-webkit-filter: brightness(1.3);
} }
.btn { .expand-collapse.expanded::before {
width: 40px; content: '▲'
height: 40px; }
border-radius: 20px;
font-size: 22px; .expand-collapse.collapsed::before {
transition: all 0.1s ease-out; content: '▼'
text-align: center; }
.expand-collapse::before {
margin-right: 5px;
}
.expand-collapse.collapsed {
display: block;
}
.collapsed {
display: none;
} }
/* /*
@@ -266,64 +270,139 @@ i:not(.disabled):hover {
width: calc(100% - 20px); width: calc(100% - 20px);
max-width: 600px; max-width: 600px;
box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.4); box-shadow: 2px 2px 10px 0px rgba(0,0,0,0.4);
background-color: white; background-color: #eee;
color: #222; color: #222;
padding: 20px; padding: 20px;
} }
.create-torrent-modal input, .modal label {
.open-torrent-address-modal input { font-size: 16px;
width: calc(100% - 100px) font-weight: bold;
} }
.create-torrent-modal .torrent-attribute { .open-torrent-address-modal input {
width: 100%;
}
.create-torrent-page {
padding: 10px 25px;
overflow: hidden;
}
.create-torrent-page .torrent-attribute {
white-space: nowrap; white-space: nowrap;
} }
.create-torrent-modal .torrent-attribute>* { .create-torrent-page .torrent-attribute>* {
display: inline-block; display: inline-block;
} }
.create-torrent-modal .torrent-attribute label { .create-torrent-page .torrent-attribute label {
width: 60px; width: 60px;
margin-right: 10px; margin-right: 10px;
vertical-align: top; vertical-align: top;
} }
.create-torrent-modal .torrent-attribute div { .create-torrent-page .torrent-attribute>div {
font-family: Consolas, monospace; width: calc(100% - 90px);
white-space: nowrap;
} }
.create-torrent-page .torrent-attribute div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-torrent-page .torrent-attribute textarea {
width: calc(100% - 80px);
height: 80px;
color: #eee;
background-color: transparent;
line-height: 1.5;
font-size: 14px;
font-family: inherit;
border-radius: 2px;
padding: 4px 6px;
}
.create-torrent-page textarea.torrent-trackers {
height: 200px;
}
.create-torrent-page input.torrent-is-private {
width: initial;
margin: 0;
}
/* /*
* BUTTONS * BUTTONS
* See https://www.google.com/design/spec/components/buttons.html
*/ */
button { a,
i { /* Links and icons */
cursor: default;
-webkit-app-region: no-drag;
}
a:not(.disabled):hover,
i:not(.disabled):hover { /* Show they're clickable without pointer: cursor */
-webkit-filter: brightness(1.3);
}
.button-round { /* Circular icon buttons, used on <i> tags */
width: 40px;
height: 40px;
border-radius: 20px;
font-size: 22px;
transition: all 0.1s ease-out;
text-align: center;
}
button { /* Rectangular text buttons */
background: transparent; background: transparent;
margin-left: 10px; margin-left: 10px;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 3px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
cursor: pointer; cursor: pointer;
color: #aaa; color: #aaa;
outline: none;
} }
button.primary { button.button-flat {
color: #0cf; color: #222;
padding: 7px 18px;
} }
button:hover { button.button-flat.light {
-webkit-filter: brightness(1.1); color: #eee;
} }
button:active { button.button-flat:hover,
-webkit-filter: brightness(1.1); button.button-flat:focus { /* Material design: focused */
text-shadow: none; background-color: rgba(153, 153, 153, 0.2);
}
button.button-flat:active { /* Material design: pressed */
background-color: rgba(153, 153, 153, 0.4);
}
button.button-raised {
background-color: #2196f3;
color: #eee;
padding: 7px 18px;
}
button.button-raised:hover,
button.button-raised:focus {
background-color: #38a0f5;
}
button.button-raised:active {
background-color: #51abf6;
} }
/* /*
@@ -336,7 +415,7 @@ input {
padding: 6px; padding: 6px;
border: 1px solid #bbb; border: 1px solid #bbb;
border-radius: 3px; border-radius: 3px;
box-shadow: 1px 1px 1px 0px rgba(0,0,0,0.1); outline: none;
} }
/* /*
@@ -595,9 +674,11 @@ body.drag .app::after {
background-position: center; background-position: center;
} }
.player video { .player .video-player {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
position: relative;
} }
/* /*
@@ -677,6 +758,8 @@ body.drag .app::after {
.player-controls .device, .player-controls .device,
.player-controls .fullscreen, .player-controls .fullscreen,
.player-controls .closed-captions,
.player-controls .volume-icon,
.player-controls .back { .player-controls .back {
display: block; display: block;
width: 20px; width: 20px;
@@ -684,11 +767,13 @@ body.drag .app::after {
margin: 5px; margin: 5px;
} }
.player-controls .volume,
.player-controls .back { .player-controls .back {
float: left; float: left;
} }
.player-controls .device, .player-controls .device,
.player-controls .closed-captions,
.player-controls .fullscreen { .player-controls .fullscreen {
float: right; float: right;
} }
@@ -697,15 +782,50 @@ body.drag .app::after {
margin-right: 15px; margin-right: 15px;
} }
.player-controls .volume-icon,
.player-controls .device { .player-controls .device {
font-size: 18px; /* make the cast icons less huge */ font-size: 18px; /* make the cast icons less huge */
margin-top: 8px !important; margin-top: 8px !important;
} }
.player-controls .closed-captions.active,
.player-controls .device.active { .player-controls .device.active {
color: #9af; color: #9af;
} }
.player-controls .volume {
display: block;
width: 90px;
}
.player-controls .volume-icon {
float: left;
margin-right: 0px;
}
.player-controls .volume-slider {
-webkit-appearance: none;
width: 50px;
height: 3px;
border: none;
padding: 0;
vertical-align: sub;
}
.player-controls .volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
background-color: #fff;
opacity: 1.0;
width: 10px;
height: 10px;
border: 1px solid #303233;
border-radius: 50%;
}
.player-controls .volume-slider:focus {
outline: none;
}
.player .playback-bar:hover .loading-bar { .player .playback-bar:hover .loading-bar {
height: 5px; height: 5px;
} }
@@ -716,6 +836,15 @@ body.drag .app::after {
height: 14px; height: 14px;
} }
::cue {
background: none;
color: #FFF;
font: 24px;
line-height: 1.3em;
text-shadow: #000 -1px 0 1px, #000 1px 0 1px, #000 0 -1px 1px, #000 0 1px 1px, rgba(50, 50, 50, 0.5) 2px 2px 0;
}
/* /*
* CHROMECAST / AIRPLAY CONTROLS * CHROMECAST / AIRPLAY CONTROLS
*/ */
@@ -742,6 +871,29 @@ body.drag .app::after {
font-weight: bold; font-weight: bold;
} }
/*
* Subtitles list
*/
.subtitles-list {
position: fixed;
background: rgba(40, 40, 40, 0.8);
min-width: 100px;
bottom: 45px;
right: 3px;
transition: opacity 0.15s ease-out;
padding: 5px 10px;
border-radius: 3px;
margin: 0;
list-style-type: none;
color: rgba(255, 255, 255, 0.8);
}
.subtitles-list i {
font-size: 11px; /* make the cast icons less huge */
margin-right: 4px !important;
}
/* /*
* MEDIA OVERLAY / AUDIO DETAILS * MEDIA OVERLAY / AUDIO DETAILS
*/ */

View File

@@ -1,38 +1,46 @@
console.time('init') console.time('init')
var cfg = require('application-config')('WebTorrent') var appConfig = require('application-config')('WebTorrent')
var concat = require('concat-stream')
var dragDrop = require('drag-drop') var dragDrop = require('drag-drop')
var electron = require('electron') var electron = require('electron')
var EventEmitter = require('events') var fs = require('fs-extra')
var fs = require('fs')
var mainLoop = require('main-loop') var mainLoop = require('main-loop')
var path = require('path') var path = require('path')
var remote = require('remote') var srtToVtt = require('srt-to-vtt')
var LanguageDetect = require('languagedetect')
var createElement = require('virtual-dom/create-element') var createElement = require('virtual-dom/create-element')
var diff = require('virtual-dom/diff') var diff = require('virtual-dom/diff')
var patch = require('virtual-dom/patch') var patch = require('virtual-dom/patch')
var App = require('./views/app') var App = require('./views/app')
var errors = require('./lib/errors')
var config = require('../config') var config = require('../config')
var crashReporter = require('../crash-reporter') var crashReporter = require('../crash-reporter')
var errors = require('./lib/errors')
var sound = require('./lib/sound')
var State = require('./state')
var TorrentPlayer = require('./lib/torrent-player') var TorrentPlayer = require('./lib/torrent-player')
var util = require('./util') var TorrentSummary = require('./lib/torrent-summary')
var {setDispatch} = require('./lib/dispatcher') var {setDispatch} = require('./lib/dispatcher')
setDispatch(dispatch) setDispatch(dispatch)
var State = require('./state')
// This dependency is the slowest-loading, so we lazy load it appConfig.filePath = config.CONFIG_PATH + path.sep + 'config.json'
var Cast = null
// Electron apps have two processes: a main process (node) runs first and starts // Electron apps have two processes: a main process (node) runs first and starts
// a renderer process (essentially a Chrome window). We're in the renderer process, // a renderer process (essentially a Chrome window). We're in the renderer process,
// and this IPC channel receives from and sends messages to the main process // and this IPC channel receives from and sends messages to the main process
var ipcRenderer = electron.ipcRenderer var ipcRenderer = electron.ipcRenderer
var clipboard = electron.clipboard var clipboard = electron.clipboard
var dialog = remote.require('dialog')
var dialog = electron.remote.dialog
var Menu = electron.remote.Menu
var MenuItem = electron.remote.MenuItem
var remote = electron.remote
// This dependency is the slowest-loading, so we lazy load it
var Cast = null
// For easy debugging in Developer Tools // For easy debugging in Developer Tools
var state = global.state = State.getInitialState() var state = global.state = State.getInitialState()
@@ -53,13 +61,17 @@ loadState(init)
* the dock icon and drag+drop. * the dock icon and drag+drop.
*/ */
function init () { function init () {
// Clean up the freshly-loaded config file, which may be from an older version
cleanUpConfig()
// Push the first page into the location history // Push the first page into the location history
state.location.go({ url: 'home' }) state.location.go({ url: 'home' })
initWebTorrent() // Restart everything we were torrenting last time the app ran
resumeTorrents()
// Lazily load the Chromecast/Airplay/DLNA modules // Lazy-load other stuff, like the AppleTV module, later to keep startup fast
window.setTimeout(lazyLoadCast, 5000) window.setTimeout(delayedInit, 5000)
// The UI is built with virtual-dom, a minimalist library extracted from React // The UI is built with virtual-dom, a minimalist library extracted from React
// The concepts--one way data flow, a pure function that renders state to a // The concepts--one way data flow, a pure function that renders state to a
@@ -72,6 +84,11 @@ function init () {
}) })
document.body.appendChild(vdomLoop.target) document.body.appendChild(vdomLoop.target)
// Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000)
// OS integrations: // OS integrations:
// ...drag and drop a torrent or video file to play or seed // ...drag and drop a torrent or video file to play or seed
dragDrop('body', (files) => dispatch('onOpen', files)) dragDrop('body', (files) => dispatch('onOpen', files))
@@ -80,41 +97,66 @@ function init () {
document.addEventListener('paste', onPaste) document.addEventListener('paste', onPaste)
// ...keyboard shortcuts // ...keyboard shortcuts
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', onKeyDown)
if (e.which === 27) { /* ESC means either exit fullscreen or go back */
if (state.modal) {
dispatch('exitModal')
} else if (state.window.isFullScreen) {
dispatch('toggleFullScreen')
} else {
dispatch('back')
}
} else if (e.which === 32) { /* spacebar pauses or plays the video */
dispatch('playPause')
}
})
// ...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', function () { window.addEventListener('focus', onFocus)
state.window.isFocused = true window.addEventListener('blur', onBlur)
state.dock.badge = 0
update()
})
window.addEventListener('blur', function () {
state.window.isFocused = false
update()
})
// Listen for messages from the main process // Listen for messages from the main process
setupIpc() setupIpc()
// Done! Ideally we want to get here <100ms after the user clicks the app // Done! Ideally we want to get here <100ms after the user clicks the app
playInterfaceSound('STARTUP') sound.play('STARTUP')
console.timeEnd('init') console.timeEnd('init')
} }
function delayedInit () {
lazyLoadCast()
sound.preload()
}
// Change `state.saved` (which will be saved back to config.json on exit) as
// needed, for example to deal with config.json format changes across versions
function cleanUpConfig () {
state.saved.torrents.forEach(function (ts) {
var infoHash = ts.infoHash
// Migration: replace torrentPath with torrentFileName
var src, dst
if (ts.torrentPath) {
console.log('migration: replacing torrentPath %s', ts.torrentPath)
src = path.isAbsolute(ts.torrentPath)
? ts.torrentPath
: path.join(config.STATIC_PATH, ts.torrentPath)
dst = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent'
}
// Migration: replace posterURL with posterFileName
if (ts.posterURL) {
console.log('migration: replacing posterURL %s', ts.posterURL)
var extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL)
? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.CONFIG_POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.posterURL
ts.posterFileName = infoHash + extension
}
})
}
// Lazily loads Chromecast and Airplay support // Lazily loads Chromecast and Airplay support
function lazyLoadCast () { function lazyLoadCast () {
if (!Cast) { if (!Cast) {
@@ -124,17 +166,6 @@ function lazyLoadCast () {
return Cast return Cast
} }
// Talk to WebTorrent process, resume torrents, start monitoring torrent progress
function initWebTorrent () {
// Restart everything we were torrenting last time the app ran
resumeTorrents()
// Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000)
}
// This is the (mostly) pure function from state -> UI. Returns a virtual DOM // This is the (mostly) pure function from state -> UI. Returns a virtual DOM
// tree. Any events, such as button clicks, will turn into calls to dispatch() // tree. Any events, such as button clicks, will turn into calls to dispatch()
function render (state) { function render (state) {
@@ -180,12 +211,15 @@ function dispatch (action, ...args) {
if (action === 'addTorrent') { if (action === 'addTorrent') {
addTorrent(args[0] /* torrent */) addTorrent(args[0] /* torrent */)
} }
if (action === 'showCreateTorrent') { if (action === 'showOpenSeedFiles') {
ipcRenderer.send('showCreateTorrent') /* open file or folder to seed */ ipcRenderer.send('showOpenSeedFiles') /* open file or folder to seed */
} }
if (action === 'showOpenTorrentFile') { if (action === 'showOpenTorrentFile') {
ipcRenderer.send('showOpenTorrentFile') /* open torrent file */ ipcRenderer.send('showOpenTorrentFile') /* open torrent file */
} }
if (action === 'showCreateTorrent') {
showCreateTorrent(args[0] /* fileOrFolder */)
}
if (action === 'createTorrent') { if (action === 'createTorrent') {
createTorrent(args[0] /* options */) createTorrent(args[0] /* options */)
} }
@@ -216,6 +250,16 @@ function dispatch (action, ...args) {
if (action === 'setDimensions') { if (action === 'setDimensions') {
setDimensions(args[0] /* dimensions */) setDimensions(args[0] /* dimensions */)
} }
if (action === 'backToList') {
while (state.location.hasBack()) state.location.back()
// Work around virtual-dom issue: it doesn't expose its redraw function,
// and only redraws on requestAnimationFrame(). That means when the user
// closes the window (hide window / minimize to tray) and we want to pause
// the video, we update the vdom but it keeps playing until you reopen!
var mediaTag = document.querySelector('video,audio')
if (mediaTag) mediaTag.pause()
}
if (action === 'back') { if (action === 'back') {
state.location.back() state.location.back()
} }
@@ -234,17 +278,7 @@ function dispatch (action, ...args) {
}, },
onbeforeunload: closePlayer onbeforeunload: closePlayer
}) })
playPause(false) play()
}
if (action === 'pause') {
playPause(true)
// Work around virtual-dom issue: it doesn't expose its redraw function,
// and only redraws on requestAnimationFrame(). That means when the user
// closes the window (hide window / minimize to tray) and we want to pause
// the video, we update the vdom but it keeps playing until you reopen!
var mediaTag = document.querySelector('video,audio')
if (mediaTag) mediaTag.pause()
} }
if (action === 'playbackJump') { if (action === 'playbackJump') {
jumpToTime(args[0] /* seconds */) jumpToTime(args[0] /* seconds */)
@@ -252,17 +286,26 @@ function dispatch (action, ...args) {
if (action === 'changeVolume') { if (action === 'changeVolume') {
changeVolume(args[0] /* increase */) changeVolume(args[0] /* increase */)
} }
if (action === 'mediaPlaying') { if (action === 'setVolume') {
state.playing.isPaused = false setVolume(args[0] /* increase */)
ipcRenderer.send('blockPowerSave')
} }
if (action === 'mediaPaused') { if (action === 'openSubtitles') {
state.playing.isPaused = true openSubtitles()
ipcRenderer.send('unblockPowerSave') }
if (action === 'selectSubtitle') {
selectSubtitle(args[0] /* label */)
}
if (action === 'showSubtitles') {
showSubtitles()
} }
if (action === 'mediaStalled') { if (action === 'mediaStalled') {
state.playing.isStalled = true state.playing.isStalled = true
} }
if (action === 'mediaError') {
state.location.back(function () {
onError(new Error('Unsupported file format'))
})
}
if (action === 'mediaTimeUpdate') { if (action === 'mediaTimeUpdate') {
state.playing.lastTimeUpdate = new Date().getTime() state.playing.lastTimeUpdate = new Date().getTime()
state.playing.isStalled = false state.playing.isStalled = false
@@ -303,16 +346,30 @@ function updateAvailable (version) {
state.modal = { id: 'update-available-modal', version: version } state.modal = { id: 'update-available-modal', version: version }
} }
// Plays or pauses the video. If isPaused is undefined, acts as a toggle function play () {
function playPause (isPaused) { if (!state.playing.isPaused) return
if (isPaused === state.playing.isPaused) { state.playing.isPaused = false
return // Nothing to do
}
// Either isPaused is undefined, or it's the opposite of the current state. Toggle.
if (isCasting()) { if (isCasting()) {
Cast.playPause() Cast.play()
}
ipcRenderer.send('blockPowerSave')
}
function pause () {
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting()) {
Cast.pause()
}
ipcRenderer.send('unblockPowerSave')
}
function playPause () {
if (state.playing.isPaused) {
play()
} else {
pause()
} }
state.playing.isPaused = !state.playing.isPaused
} }
function jumpToTime (time) { function jumpToTime (time) {
@@ -328,7 +385,6 @@ function changeVolume (delta) {
setVolume(state.playing.volume + delta) setVolume(state.playing.volume + delta)
} }
// TODO: never called. Either remove or make a volume control that calls it
function setVolume (volume) { function setVolume (volume) {
// check if its in [0.0 - 1.0] range // check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume)) volume = Math.max(0, Math.min(1, volume))
@@ -339,6 +395,17 @@ function setVolume (volume) {
} }
} }
function openSubtitles () {
dialog.showOpenDialog({
title: 'Select a subtitles file.',
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
properties: [ 'openFile' ]
}, function (filenames) {
if (!Array.isArray(filenames)) return
addSubtitle({path: filenames[0]})
})
}
// Checks whether we are connected and already casting // Checks whether we are connected and already casting
// Returns false if we not casting (state.playing.location === 'local') // Returns false if we not casting (state.playing.location === 'local')
// or if we're trying to connect but haven't yet ('chromecast-pending', etc) // or if we're trying to connect but haven't yet ('chromecast-pending', etc)
@@ -366,13 +433,6 @@ function setupIpc () {
update() update()
}) })
ipcRenderer.on('addFakeDevice', function (e, device) {
var player = new EventEmitter()
player.play = (networkURL) => console.log(networkURL)
state.devices[device] = player
update()
})
ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args)) ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => torrentMetadata(...args)) ipcRenderer.on('wt-metadata', (e, ...args) => torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => torrentDone(...args)) ipcRenderer.on('wt-done', (e, ...args) => torrentDone(...args))
@@ -389,9 +449,9 @@ function setupIpc () {
// Load state.saved from the JSON state file // Load state.saved from the JSON state file
function loadState (cb) { function loadState (cb) {
cfg.read(function (err, data) { appConfig.read(function (err, data) {
if (err) console.error(err) if (err) console.error(err)
console.log('loaded state from ' + cfg.filePath) console.log('loaded state from ' + appConfig.filePath)
// populate defaults if they're not there // populate defaults if they're not there
state.saved = Object.assign({}, State.getDefaultSavedState(), data) state.saved = Object.assign({}, State.getDefaultSavedState(), data)
@@ -421,7 +481,7 @@ function saveStateThrottled () {
// Write state.saved to the JSON state file // Write state.saved to the JSON state file
function saveState () { function saveState () {
console.log('saving state to ' + cfg.filePath) console.log('saving state to ' + appConfig.filePath)
// Clean up, so that we're not saving any pending state // Clean up, so that we're not saving any pending state
var copy = Object.assign({}, state.saved) var copy = Object.assign({}, state.saved)
@@ -443,7 +503,7 @@ function saveState () {
return torrent return torrent
}) })
cfg.write(copy, function (err) { appConfig.write(copy, function (err) {
if (err) console.error(err) if (err) console.error(err)
ipcRenderer.send('savedState') ipcRenderer.send('savedState')
}) })
@@ -456,23 +516,14 @@ function onOpen (files) {
if (!Array.isArray(files)) files = [ files ] if (!Array.isArray(files)) files = [ files ]
// .torrent file = start downloading the torrent // .torrent file = start downloading the torrent
files.filter(isTorrent).forEach(function (torrentFile) { files.filter(isTorrent).forEach(addTorrent)
addTorrent(torrentFile)
}) // subtitle file
files.filter(isSubtitle).forEach(addSubtitle)
// everything else = seed these files // everything else = seed these files
createTorrentFromFileObjects(files.filter(isNotTorrent)) var rest = files.filter(not(isTorrent)).filter(not(isSubtitle))
} if (rest.length > 0) showCreateTorrent(rest)
function onPaste (e) {
if (e.target.tagName.toLowerCase() === 'input') return
var torrentIds = clipboard.readText().split('\n')
torrentIds.forEach(function (torrentId) {
torrentId = torrentId.trim()
if (torrentId.length === 0) return
dispatch('addTorrent', torrentId)
})
} }
function isTorrent (file) { function isTorrent (file) {
@@ -482,8 +533,16 @@ function isTorrent (file) {
return isTorrentFile || isMagnet return isTorrentFile || isMagnet
} }
function isNotTorrent (file) { function isSubtitle (file) {
return !isTorrent(file) var name = typeof file === 'string' ? file : file.name
var ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt'
}
function not (test) {
return function (...args) {
return !test(...args)
}
} }
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents // Gets a torrent summary {name, infoHash, status} from state.saved.torrents
@@ -506,6 +565,48 @@ function addTorrent (torrentId) {
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path) ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
} }
function addSubtitle (file) {
if (state.playing.type !== 'video') return
fs.createReadStream(file.path || file).pipe(srtToVtt()).pipe(concat(function (buf) {
// Set the cue text position so it appears above the player controls.
// The only way to change cue text position is by modifying the VTT. It is not
// possible via CSS.
var langDetected = (new LanguageDetect()).detect(buf.toString().replace(/(.*-->.*)/g, ''), 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var subtitles = Buffer(buf.toString().replace(/(-->.*)/g, '$1 line:88%'))
var track = {
buffer: 'data:text/vtt;base64,' + subtitles.toString('base64'),
label: langDetected,
selected: true
}
state.playing.subtitles.tracks.forEach(function (trackItem) {
trackItem.selected = false
if (trackItem.label === track.label) {
track.label = Number.isNaN(track.label.slice(-1))
? track.label + ' 2'
: track.label.slice(0, -1) + (parseInt(track.label.slice(-1)) + 1)
}
})
state.playing.subtitles.change = track.label
state.playing.subtitles.tracks.push(track)
state.playing.subtitles.enabled = true
}))
}
function selectSubtitle (label) {
state.playing.subtitles.tracks.forEach(function (track) {
track.selected = (track.label === label)
})
state.playing.subtitles.enabled = !!label
state.playing.subtitles.change = label
state.playing.subtitles.show = false
}
function showSubtitles () {
state.playing.subtitles.show = !state.playing.subtitles.show
}
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object // Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
function startTorrentingSummary (torrentSummary) { function startTorrentingSummary (torrentSummary) {
var s = torrentSummary var s = torrentSummary
@@ -517,8 +618,8 @@ function startTorrentingSummary (torrentSummary) {
var path = s.path || state.saved.downloadPath var path = s.path || state.saved.downloadPath
var torrentID var torrentID
if (s.torrentPath) { // Load torrent file from disk if (s.torrentFileName) { // Load torrent file from disk
torrentID = util.getAbsoluteStaticPath(s.torrentPath) torrentID = TorrentSummary.getTorrentPath(torrentSummary)
} else { // Load torrent from DHT } else { // Load torrent from DHT
torrentID = s.magnetURI || s.infoHash torrentID = s.magnetURI || s.infoHash
} }
@@ -526,62 +627,58 @@ function startTorrentingSummary (torrentSummary) {
ipcRenderer.send('wt-start-torrenting', s.torrentKey, torrentID, path, s.fileModtimes) ipcRenderer.send('wt-start-torrenting', s.torrentKey, torrentID, path, s.fileModtimes)
} }
// TODO: maybe have a "create torrent" modal in the future, with options like
// custom trackers, private flag, and so on?
//
// Right now create-torrent-modal is v basic, only user input is OK / Cancel
//
// Also, if you uncomment below below, creating a torrent thru
// File > Create New Torrent will still create a new torrent directly, while
// dragging files or folders onto the app opens the create-torrent-modal
//
// That's because the former gets a single string and the latter gets a list
// of W3C File objects. We should fix this inconsistency, ideally without
// duping this code in the drag-drop module:
// https://github.com/feross/drag-drop/blob/master/index.js
//
// function showCreateTorrentModal (files) {
// if (files.length === 0) return
// state.modal = {
// id: 'create-torrent-modal',
// files: files
// }
// }
// //
// TORRENT MANAGEMENT // TORRENT MANAGEMENT
// Send commands to the WebTorrent process, handle events // Send commands to the WebTorrent process, handle events
// //
// Creates a new torrent from a drag-dropped file or folder // Shows the Create Torrent page with options to seed a given file or folder
function createTorrentFromFileObjects (files) { function showCreateTorrent (files) {
var filePaths = files.map((x) => x.path) if (Array.isArray(files)) {
if (state.location.pending() || state.location.current().url !== 'home') return
// Single-file torrents are easy. Multi-file torrents require special handling state.location.go({
// make sure WebTorrent seeds all files in place, without copying to /tmp url: 'create-torrent',
if (filePaths.length === 1) { files: files
return createTorrent({files: filePaths[0]}) })
return
} }
// First, extract the base folder that the files are all in var fileOrFolder = files
var pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix) findFilesRecursive(fileOrFolder, showCreateTorrent)
if (files.length > 0 && !pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) { }
pathPrefix = path.dirname(pathPrefix)
}
// Then, use the name of the base folder (or sole file, for a single file torrent) // Recursively finds {name, path, size} for all files in a folder
// as the default name. Show all files relative to the base folder. // Calls `cb` on success, calls `onError` on failure
var defaultName = path.basename(pathPrefix) function findFilesRecursive (fileOrFolder, cb) {
var basePath = path.dirname(pathPrefix) fs.stat(fileOrFolder, function (err, stat) {
var options = { if (err) return onError(err)
// TODO: we can't let the user choose their own name if we want WebTorrent
// to use the files in place rather than creating a new folder.
name: defaultName,
path: basePath,
files: filePaths
}
createTorrent(options) // Files: return name, path, and size
if (!stat.isDirectory()) {
var filePath = fileOrFolder
return cb([{
name: path.basename(filePath),
path: filePath,
size: stat.size
}])
}
// Folders: recurse, make a list of all the files
var folderPath = fileOrFolder
fs.readdir(folderPath, function (err, fileNames) {
if (err) return onError(err)
var numComplete = 0
var ret = []
fileNames.forEach(function (fileName) {
findFilesRecursive(path.join(folderPath, fileName), function (fileObjs) {
ret = ret.concat(fileObjs)
if (++numComplete === fileNames.length) {
cb(ret)
}
})
})
})
})
} }
// Creates a new torrent and start seeeding // Creates a new torrent and start seeeding
@@ -601,7 +698,7 @@ function torrentInfoHash (torrentKey, infoHash) {
status: 'new' status: 'new'
} }
state.saved.torrents.push(torrentSummary) state.saved.torrents.push(torrentSummary)
playInterfaceSound('ADD') sound.play('ADD')
} }
torrentSummary.infoHash = infoHash torrentSummary.infoHash = infoHash
@@ -617,7 +714,7 @@ function torrentError (torrentKey, message) {
// TODO: WebTorrent should have semantic errors // TODO: WebTorrent should have semantic errors
if (message.startsWith('There is already a swarm')) { if (message.startsWith('There is already a swarm')) {
onError(new Error('Couldn\'t add duplicate torrent')) onError(new Error('Can\'t add duplicate torrent'))
} else if (!torrentSummary) { } else if (!torrentSummary) {
onError(message) onError(message)
} else { } else {
@@ -639,10 +736,10 @@ function torrentMetadata (torrentKey, torrentInfo) {
update() update()
// Save the .torrent file, if it hasn't been saved already // Save the .torrent file, if it hasn't been saved already
if (!torrentSummary.torrentPath) ipcRenderer.send('wt-save-torrent-file', torrentKey) if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey)
// Auto-generate a poster image, if it hasn't been generated already // Auto-generate a poster image, if it hasn't been generated already
if (!torrentSummary.posterURL) ipcRenderer.send('wt-generate-torrent-poster', torrentKey) if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey)
} }
function torrentDone (torrentKey, torrentInfo) { function torrentDone (torrentKey, torrentInfo) {
@@ -695,16 +792,16 @@ function torrentFileModtimes (torrentKey, fileModtimes) {
saveStateThrottled() saveStateThrottled()
} }
function torrentFileSaved (torrentKey, torrentPath) { function torrentFileSaved (torrentKey, torrentFileName) {
console.log('torrent file saved %s: %s', torrentKey, torrentPath) console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
var torrentSummary = getTorrentSummary(torrentKey) var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.torrentPath = torrentPath torrentSummary.torrentFileName = torrentFileName
saveStateThrottled() saveStateThrottled()
} }
function torrentPosterSaved (torrentKey, posterPath) { function torrentPosterSaved (torrentKey, posterFileName) {
var torrentSummary = getTorrentSummary(torrentKey) var torrentSummary = getTorrentSummary(torrentKey)
torrentSummary.posterURL = posterPath torrentSummary.posterFileName = posterFileName
saveStateThrottled() saveStateThrottled()
} }
@@ -741,12 +838,6 @@ function pickFileToPlay (files) {
return undefined return undefined
} }
function stopServer () {
ipcRenderer.send('wt-stop-server')
state.playing = State.getDefaultPlayState()
state.server = null
}
// Opens the video player // Opens the video player
function openPlayer (infoHash, index, cb) { function openPlayer (infoHash, index, cb) {
var torrentSummary = getTorrentSummary(infoHash) var torrentSummary = getTorrentSummary(infoHash)
@@ -756,13 +847,13 @@ function openPlayer (infoHash, index, cb) {
if (index === undefined) return cb(new errors.UnplayableError()) if (index === undefined) return cb(new errors.UnplayableError())
// update UI to show pending playback // update UI to show pending playback
if (torrentSummary.progress !== 1) playInterfaceSound('PLAY') if (torrentSummary.progress !== 1) sound.play('PLAY')
torrentSummary.playStatus = 'requested' torrentSummary.playStatus = 'requested'
update() update()
var timeout = setTimeout(function () { var timeout = setTimeout(function () {
torrentSummary.playStatus = 'timeout' /* no seeders available? */ torrentSummary.playStatus = 'timeout' /* no seeders available? */
playInterfaceSound('ERROR') sound.play('ERROR')
cb(new Error('playback timed out')) cb(new Error('playback timed out'))
update() update()
}, 10000) /* give it a few seconds */ }, 10000) /* give it a few seconds */
@@ -814,23 +905,23 @@ function openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
} }
function closePlayer (cb) { function closePlayer (cb) {
state.window.title = config.APP_WINDOW_TITLE
update() /* needed for OSX: toggleFullScreen animation w/ correct title */
if (isCasting()) { if (isCasting()) {
Cast.close() Cast.close()
} }
state.window.title = config.APP_WINDOW_TITLE
state.playing = State.getDefaultPlayState()
state.server = null
if (state.window.isFullScreen) { if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false) dispatch('toggleFullScreen', false)
} }
restoreBounds() restoreBounds()
stopServer()
update()
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('unblockPowerSave') ipcRenderer.send('unblockPowerSave')
ipcRenderer.send('onPlayerClose') ipcRenderer.send('onPlayerClose')
update()
cb() cb()
} }
@@ -859,11 +950,11 @@ function toggleTorrent (infoHash) {
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new' torrentSummary.status = 'new'
startTorrentingSummary(torrentSummary) startTorrentingSummary(torrentSummary)
playInterfaceSound('ENABLE') sound.play('ENABLE')
} else { } else {
torrentSummary.status = 'paused' torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash) ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
playInterfaceSound('DISABLE') sound.play('DISABLE')
} }
} }
@@ -875,7 +966,7 @@ function deleteTorrent (infoHash) {
if (index > -1) state.saved.torrents.splice(index, 1) if (index > -1) state.saved.torrents.splice(index, 1)
saveStateThrottled() saveStateThrottled()
state.location.clearForward() // prevent user from going forward to a deleted torrent state.location.clearForward() // prevent user from going forward to a deleted torrent
playInterfaceSound('DELETE') sound.play('DELETE')
} }
function toggleSelectTorrent (infoHash) { function toggleSelectTorrent (infoHash) {
@@ -886,18 +977,18 @@ function toggleSelectTorrent (infoHash) {
function openTorrentContextMenu (infoHash) { function openTorrentContextMenu (infoHash) {
var torrentSummary = getTorrentSummary(infoHash) var torrentSummary = getTorrentSummary(infoHash)
var menu = new remote.Menu() var menu = new Menu()
menu.append(new remote.MenuItem({ menu.append(new MenuItem({
label: 'Save Torrent File As...', label: 'Save Torrent File As...',
click: () => saveTorrentFileAs(torrentSummary) click: () => saveTorrentFileAs(torrentSummary)
})) }))
menu.append(new remote.MenuItem({ menu.append(new MenuItem({
label: 'Copy Instant.io Link to Clipboard', label: 'Copy Instant.io Link to Clipboard',
click: () => clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`) click: () => clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`)
})) }))
menu.append(new remote.MenuItem({ menu.append(new MenuItem({
label: 'Copy Magnet Link to Clipboard', label: 'Copy Magnet Link to Clipboard',
click: () => clipboard.writeText(torrentSummary.magnetURI) click: () => clipboard.writeText(torrentSummary.magnetURI)
})) }))
@@ -915,8 +1006,8 @@ function saveTorrentFileAs (torrentSummary) {
{ name: 'All Files', extensions: ['*'] } { name: 'All Files', extensions: ['*'] }
] ]
} }
dialog.showSaveDialog(remote.getCurrentWindow(), opts, (savePath) => { dialog.showSaveDialog(remote.getCurrentWindow(), opts, function (savePath) {
var torrentPath = util.getAbsoluteStaticPath(torrentSummary.torrentPath) var torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, function (err, torrentFile) { fs.readFile(torrentPath, function (err, torrentFile) {
if (err) return onError(err) if (err) return onError(err)
fs.writeFile(savePath, torrentFile, function (err) { fs.writeFile(savePath, torrentFile, function (err) {
@@ -951,15 +1042,17 @@ function setDimensions (dimensions) {
Math.min(screenWidth / dimensions.width, 1), Math.min(screenWidth / dimensions.width, 1),
Math.min(screenHeight / dimensions.height, 1) Math.min(screenHeight / dimensions.height, 1)
) )
var width = Math.floor(dimensions.width * scaleFactor) var width = Math.max(
var height = Math.floor(dimensions.height * scaleFactor) Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH
// Center window on screen )
var x = Math.floor((screenWidth - width) / 2) var height = Math.max(
var y = Math.floor((screenHeight - height) / 2) Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT
)
ipcRenderer.send('setAspectRatio', aspectRatio) ipcRenderer.send('setAspectRatio', aspectRatio)
ipcRenderer.send('setBounds', {x, y, width, height}) ipcRenderer.send('setBounds', {x: null, y: null, width, height})
} }
function restoreBounds () { function restoreBounds () {
@@ -969,20 +1062,6 @@ function restoreBounds () {
} }
} }
function onError (err) {
console.error(err.stack || err)
playInterfaceSound('ERROR')
state.errors.push({
time: new Date().getTime(),
message: err.message || err
})
update()
}
function onWarning (err) {
console.log('warning: %s', err.message || err)
}
function showDoneNotification (torrent) { function showDoneNotification (torrent) {
var notif = new window.Notification('Download Complete', { var notif = new window.Notification('Download Complete', {
body: torrent.name, body: torrent.name,
@@ -993,27 +1072,7 @@ function showDoneNotification (torrent) {
ipcRenderer.send('focusWindow', 'main') ipcRenderer.send('focusWindow', 'main')
} }
playInterfaceSound('DONE') sound.play('DONE')
}
function playInterfaceSound (name) {
var sound = config[`SOUND_${name}`]
if (!sound) throw new Error('Invalid sound name')
var audio = new window.Audio()
audio.volume = sound.volume
audio.src = sound.url
audio.play()
}
// 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)
} }
// Hide player controls while playing video, if the mouse stays still for a while // Hide player controls while playing video, if the mouse stays still for a while
@@ -1034,3 +1093,54 @@ function showOrHidePlayerControls () {
} }
return false return false
} }
// Event handlers
function onError (err) {
console.error(err.stack || err)
sound.play('ERROR')
state.errors.push({
time: new Date().getTime(),
message: err.message || err
})
update()
}
function onWarning (err) {
console.log('warning: %s', err.message || err)
}
function onPaste (e) {
if (e.target.tagName.toLowerCase() === 'input') return
var torrentIds = clipboard.readText().split('\n')
torrentIds.forEach(function (torrentId) {
torrentId = torrentId.trim()
if (torrentId.length === 0) return
dispatch('addTorrent', torrentId)
})
}
function onKeyDown (e) {
if (e.which === 27) { /* ESC means either exit fullscreen or go back */
if (state.modal) {
dispatch('exitModal')
} else if (state.window.isFullScreen) {
dispatch('toggleFullScreen')
} else {
dispatch('back')
}
} else if (e.which === 32) { /* spacebar pauses or plays the video */
dispatch('playPause')
}
}
function onFocus (e) {
state.window.isFocused = true
state.dock.badge = 0
update()
}
function onBlur () {
state.window.isFocused = false
update()
}

View File

@@ -5,7 +5,8 @@ module.exports = {
init, init,
open, open,
close, close,
playPause, play,
pause,
seek, seek,
setVolume setVolume
} }
@@ -81,9 +82,12 @@ function chromecastPlayer (player) {
}) })
} }
function playPause (callback) { function play (callback) {
if (!state.playing.isPaused) player.pause(callback) player.play(null, null, callback)
else player.play(null, null, callback) }
function pause (callback) {
player.pause(callback)
} }
function stop (callback) { function stop (callback) {
@@ -113,7 +117,8 @@ function chromecastPlayer (player) {
return { return {
player: player, player: player,
open: open, open: open,
playPause: playPause, play: play,
pause: pause,
stop: stop, stop: stop,
status: status, status: status,
seek: seek, seek: seek,
@@ -138,9 +143,12 @@ function airplayPlayer (player) {
}) })
} }
function playPause (callback) { function play (callback) {
if (!state.playing.isPaused) player.rate(0, callback) player.rate(1, callback)
else player.rate(1, callback) }
function pause (callback) {
player.rate(0, callback)
} }
function stop (callback) { function stop (callback) {
@@ -172,7 +180,8 @@ function airplayPlayer (player) {
return { return {
player: player, player: player,
open: open, open: open,
playPause: playPause, play: play,
pause: pause,
stop: stop, stop: stop,
status: status, status: status,
seek: seek, seek: seek,
@@ -217,9 +226,12 @@ function dlnaPlayer (player) {
}) })
} }
function playPause (callback) { function play (callback) {
if (!state.playing.isPaused) player.pause(callback) player.play(null, null, callback)
else player.play(null, null, callback) }
function pause (callback) {
player.pause(callback)
} }
function stop (callback) { function stop (callback) {
@@ -253,7 +265,8 @@ function dlnaPlayer (player) {
return { return {
player: player, player: player,
open: open, open: open,
playPause: playPause, play: play,
pause: pause,
stop: stop, stop: stop,
status: status, status: status,
seek: seek, seek: seek,
@@ -317,10 +330,17 @@ function getDevice (location) {
} }
} }
function playPause () { function play () {
var device = getDevice() var device = getDevice()
if (device) { if (device) {
device.playPause(castCallback) device.play(castCallback)
}
}
function pause () {
var device = getDevice()
if (device) {
device.pause(castCallback)
} }
} }

View File

@@ -20,11 +20,13 @@ function dispatcher (...args) {
var json = JSON.stringify(args) var json = JSON.stringify(args)
var handler = _dispatchers[json] var handler = _dispatchers[json]
if (!handler) { if (!handler) {
_dispatchers[json] = (e) => { handler = _dispatchers[json] = (e) => {
// Don't click on whatever is below the button if (e && e.stopPropagation && e.currentTarget) {
e.stopPropagation() // Don't click on whatever is below the button
// Don't regisiter clicks on disabled buttons e.stopPropagation()
if (e.currentTarget.classList.contains('disabled')) return // Don't register clicks on disabled buttons
if (e.currentTarget.classList.contains('disabled')) return
}
_dispatch.apply(null, args) _dispatch.apply(null, args)
} }
} }

View File

@@ -7,46 +7,56 @@ function LocationHistory () {
this._pending = null this._pending = null
} }
LocationHistory.prototype.go = function (page) { LocationHistory.prototype.go = function (page, cb) {
console.log('go', page) console.log('go', page)
this.clearForward() this.clearForward()
this._go(page) this._go(page, cb)
} }
LocationHistory.prototype._go = function (page) { LocationHistory.prototype._go = function (page, cb) {
if (this._pending) return if (this._pending) return
if (page.onbeforeload) { if (page.onbeforeload) {
this._pending = page this._pending = page
page.onbeforeload((err) => { page.onbeforeload((err) => {
if (this._pending !== page) return /* navigation was cancelled */ if (this._pending !== page) return /* navigation was cancelled */
this._pending = null this._pending = null
if (err) return if (err) {
if (cb) cb(err)
return
}
this._history.push(page) this._history.push(page)
if (cb) cb()
}) })
} else { } else {
this._history.push(page) this._history.push(page)
if (cb) cb()
} }
} }
LocationHistory.prototype.back = function () { LocationHistory.prototype.back = function (cb) {
if (this._history.length <= 1) return if (this._history.length <= 1) return
var page = this._history.pop() var page = this._history.pop()
if (page.onbeforeunload) { if (page.onbeforeunload) {
// TODO: this is buggy. If the user clicks back twice, then those pages
// may end up in _forward in the wrong order depending on which onbeforeunload
// call finishes first.
page.onbeforeunload(() => { page.onbeforeunload(() => {
this._forward.push(page) this._forward.push(page)
if (cb) cb()
}) })
} else { } else {
this._forward.push(page) this._forward.push(page)
if (cb) cb()
} }
} }
LocationHistory.prototype.forward = function () { LocationHistory.prototype.forward = function (cb) {
if (this._forward.length === 0) return if (this._forward.length === 0) return
var page = this._forward.pop() var page = this._forward.pop()
this._go(page) this._go(page, cb)
} }
LocationHistory.prototype.clearForward = function () { LocationHistory.prototype.clearForward = function () {

71
renderer/lib/sound.js Normal file
View File

@@ -0,0 +1,71 @@
module.exports = {
preload,
play
}
var config = require('../../config')
var path = require('path')
/* Cache of Audio elements, for instant playback */
var cache = {}
var sounds = {
ADD: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
volume: 0.2
},
DELETE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
volume: 0.1
},
DISABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
volume: 0.2
},
DONE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
volume: 0.2
},
ENABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
volume: 0.2
},
ERROR: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
volume: 0.2
},
PLAY: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'play.wav'),
volume: 0.2
},
STARTUP: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
volume: 0.4
}
}
function preload () {
for (var name in sounds) {
if (!cache[name]) {
var sound = sounds[name]
var audio = cache[name] = new window.Audio()
audio.volume = sound.volume
audio.src = sound.url
}
}
}
function play (name) {
var audio = cache[name]
if (!audio) {
var sound = sounds[name]
if (!sound) {
throw new Error('Invalid sound name')
}
audio = cache[name] = new window.Audio()
audio.volume = sound.volume
audio.src = sound.url
}
audio.currentTime = 0
audio.play()
}

View File

@@ -15,12 +15,12 @@ function isPlayable (file) {
} }
function isVideo (file) { function isVideo (file) {
var ext = path.extname(file.name) var ext = path.extname(file.name).toLowerCase()
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(ext) !== -1 return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(ext) !== -1
} }
function isAudio (file) { function isAudio (file) {
var ext = path.extname(file.name) var ext = path.extname(file.name).toLowerCase()
return ['.mp3', '.aac', '.ogg', '.wav'].indexOf(ext) !== -1 return ['.mp3', '.aac', '.ogg', '.wav'].indexOf(ext) !== -1
} }

View File

@@ -20,7 +20,7 @@ function torrentPoster (torrent, cb) {
function getLargestFileByExtension (torrent, extensions) { function getLargestFileByExtension (torrent, extensions) {
var files = torrent.files.filter(function (file) { var files = torrent.files.filter(function (file) {
var extname = path.extname(file.name) var extname = path.extname(file.name).toLowerCase()
return extensions.indexOf(extname) !== -1 return extensions.indexOf(extname) !== -1
}) })
if (files.length === 0) return undefined if (files.length === 0) return undefined
@@ -64,6 +64,8 @@ function torrentPosterFromVideo (file, torrent, cb) {
server.destroy() server.destroy()
if (buf.length === 0) return cb(new Error('Generated poster contains no data'))
cb(null, buf, '.jpg') cb(null, buf, '.jpg')
} }
} }

View File

@@ -0,0 +1,24 @@
module.exports = {
getPosterPath,
getTorrentPath
}
var path = require('path')
var config = require('../../config')
// Expects a torrentSummary
// Returns an absolute path to the torrent file, or null if unavailable
function getTorrentPath (torrentSummary) {
if (!torrentSummary || !torrentSummary.torrentFileName) return null
return path.join(config.CONFIG_TORRENT_PATH, torrentSummary.torrentFileName)
}
// Expects a torrentSummary
// Returns an absolute path to the poster image, or null if unavailable
function getPosterPath (torrentSummary) {
if (!torrentSummary || !torrentSummary.posterFileName) return null
var posterPath = path.join(config.CONFIG_POSTER_PATH, torrentSummary.posterFileName)
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
// Backslashes in URLS in CSS cause bizarre string encoding issues
return posterPath.replace(/\\/g, '/')
}

View File

@@ -1,6 +1,8 @@
var os = require('os') var electron = require('electron')
var path = require('path') var path = require('path')
var remote = electron.remote
var config = require('../config') var config = require('../config')
var LocationHistory = require('./lib/location-history') var LocationHistory = require('./lib/location-history')
@@ -43,6 +45,7 @@ function getInitialState () {
/* /*
* Saved state is read from and written to a file every time the app runs. * Saved state is read from and written to a file every time the app runs.
* It should be simple and minimal and must be JSON. * It should be simple and minimal and must be JSON.
* It must never contain absolute paths since we have a portable app.
* *
* Config path: * Config path:
* *
@@ -70,7 +73,11 @@ function getDefaultPlayState () {
isPaused: true, isPaused: true,
isStalled: false, isStalled: false,
lastTimeUpdate: 0, /* Unix time in ms */ lastTimeUpdate: 0, /* Unix time in ms */
mouseStationarySince: 0 /* Unix time in ms */ mouseStationarySince: 0, /* Unix time in ms */
subtitles: {
tracks: [], /* subtitles file (Buffer) */
enabled: false
}
} }
} }
@@ -88,10 +95,8 @@ function getDefaultSavedState () {
torrentPath: 'bigBuckBunny.torrent', torrentPath: 'bigBuckBunny.torrent',
files: [ files: [
{ {
'name': 'bbb_sunflower_1080p_30fps_normal.mp4', length: 276134947,
'length': 276134947, name: 'bbb_sunflower_1080p_30fps_normal.mp4'
'numPiecesPresent': 0,
'numPieces': 527
} }
] ]
}, },
@@ -104,10 +109,8 @@ function getDefaultSavedState () {
torrentPath: 'sintel.torrent', torrentPath: 'sintel.torrent',
files: [ files: [
{ {
'name': 'sintel.mp4', length: 129241752,
'length': 129241752, name: 'sintel.mp4'
'numPiecesPresent': 0,
'numPieces': 987
} }
] ]
}, },
@@ -120,10 +123,8 @@ function getDefaultSavedState () {
torrentPath: 'tearsOfSteel.torrent', torrentPath: 'tearsOfSteel.torrent',
files: [ files: [
{ {
'name': 'tears_of_steel_1080p.webm', length: 571346576,
'length': 571346576, name: 'tears_of_steel_1080p.webm'
'numPiecesPresent': 0,
'numPieces': 2180
} }
] ]
}, },
@@ -136,62 +137,128 @@ function getDefaultSavedState () {
torrentPath: 'cosmosLaundromat.torrent', torrentPath: 'cosmosLaundromat.torrent',
files: [ files: [
{ {
'name': 'Cosmos Laundromat - First Cycle (1080p).gif', length: 223580,
'length': 223580, name: 'Cosmos Laundromat - First Cycle (1080p).gif'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'Cosmos Laundromat - First Cycle (1080p).mp4', length: 220087570,
'length': 220087570, name: 'Cosmos Laundromat - First Cycle (1080p).mp4'
'numPiecesPresent': 0,
'numPieces': 421
}, },
{ {
'name': 'Cosmos Laundromat - First Cycle (1080p).ogv', length: 56832560,
'length': 56832560, name: 'Cosmos Laundromat - First Cycle (1080p).ogv'
'numPiecesPresent': 0,
'numPieces': 109
}, },
{ {
'name': 'CosmosLaundromat-FirstCycle1080p.en.srt', length: 3949,
'length': 3949, name: 'CosmosLaundromat-FirstCycle1080p.en.srt'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'CosmosLaundromat-FirstCycle1080p.es.srt', length: 3907,
'length': 3907, name: 'CosmosLaundromat-FirstCycle1080p.es.srt'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'CosmosLaundromat-FirstCycle1080p.fr.srt', length: 4119,
'length': 4119, name: 'CosmosLaundromat-FirstCycle1080p.fr.srt'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'CosmosLaundromat-FirstCycle1080p.it.srt', length: 3941,
'length': 3941, name: 'CosmosLaundromat-FirstCycle1080p.it.srt'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'CosmosLaundromatFirstCycle_meta.sqlite', length: 11264,
'length': 11264, name: 'CosmosLaundromatFirstCycle_meta.sqlite'
'numPiecesPresent': 0,
'numPieces': 1
}, },
{ {
'name': 'CosmosLaundromatFirstCycle_meta.xml', length: 1204,
'length': 1204, name: 'CosmosLaundromatFirstCycle_meta.xml'
'numPiecesPresent': 0, }
'numPieces': 1 ]
},
{
status: 'paused',
infoHash: '3ba219a8634bf7bae3d848192b2da75ae995589d',
magnetURI: 'magnet:?xt=urn:btih:3ba219a8634bf7bae3d848192b2da75ae995589d&dn=The+WIRED+CD+-+Rip.+Sample.+Mash.+Share.&tr=udp%3A%2F%2Fexodus.desync.com%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&tr=wss%3A%2F%2Ftracker.webtorrent.io&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F',
displayName: 'The WIRED CD - Rip. Sample. Mash. Share.',
posterURL: 'wired-cd.jpg',
torrentPath: 'wired-cd.torrent',
files: [
{
length: 1964275,
name: '01 - Beastie Boys - Now Get Busy.mp3'
},
{
length: 3610523,
name: '02 - David Byrne - My Fair Lady.mp3'
},
{
length: 2759377,
name: '03 - Zap Mama - Wadidyusay.mp3'
},
{
length: 5816537,
name: '04 - My Morning Jacket - One Big Holiday.mp3'
},
{
length: 2106421,
name: '05 - Spoon - Revenge!.mp3'
},
{
length: 3347550,
name: '06 - Gilberto Gil - Oslodum.mp3'
},
{
length: 2107577,
name: '07 - Dan The Automator - Relaxation Spa Treatment.mp3'
},
{
length: 3108130,
name: '08 - Thievery Corporation - Dc 3000.mp3'
},
{
length: 3051528,
name: '09 - Le Tigre - Fake French.mp3'
},
{
length: 3270259,
name: '10 - Paul Westerberg - Looking Up In Heaven.mp3'
},
{
length: 3263528,
name: '11 - Chuck D - No Meaning No (feat. Fine Arts Militia).mp3'
},
{
length: 6380952,
name: '12 - The Rapture - Sister Saviour (Blackstrobe Remix).mp3'
},
{
length: 6550396,
name: '13 - Cornelius - Wataridori 2.mp3'
},
{
length: 3034692,
name: '14 - DJ Danger Mouse - What U Sittin\' On (feat. Jemini, Cee Lo And Tha Alkaholiks).mp3'
},
{
length: 3854611,
name: '15 - DJ Dolores - Oslodum 2004.mp3'
},
{
length: 1762120,
name: '16 - Matmos - Action At A Distance.mp3'
},
{
length: 4071,
name: 'README.md'
},
{
length: 78163,
name: 'poster.jpg'
} }
] ]
} }
], ],
downloadPath: path.join(os.homedir(), 'Downloads') downloadPath: config.IS_PORTABLE
? path.join(config.CONFIG_PATH, 'Downloads')
: remote.app.getPath('downloads')
} }
} }

View File

@@ -1,9 +0,0 @@
var path = require('path')
var config = require('../config')
exports.getAbsoluteStaticPath = function (filePath) {
return path.isAbsolute(filePath)
? filePath
: path.join(config.STATIC_PATH, filePath)
}

View File

@@ -5,15 +5,17 @@ var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
var Header = require('./header') var Header = require('./header')
var Player = require('./player') var Views = {
var TorrentList = require('./torrent-list') 'home': require('./torrent-list'),
'player': require('./player'),
'create-torrent': require('./create-torrent-page')
}
var Modals = { var Modals = {
'open-torrent-address-modal': require('./open-torrent-address-modal'), 'open-torrent-address-modal': require('./open-torrent-address-modal'),
'update-available-modal': require('./update-available-modal'), 'update-available-modal': require('./update-available-modal')
'create-torrent-modal': require('./create-torrent-modal')
} }
function App (state, dispatch) { function App (state) {
// Hide player controls while playing video, if the mouse stays still for a while // Hide player controls while playing video, if the mouse stays still for a while
// Never hide the controls when: // Never hide the controls when:
// * The mouse is over the controls or we're scrubbing (see CSS) // * The mouse is over the controls or we're scrubbing (see CSS)
@@ -40,47 +42,43 @@ function App (state, dispatch) {
return hx` return hx`
<div class='app ${cls.join(' ')}'> <div class='app ${cls.join(' ')}'>
${Header(state, dispatch)} ${Header(state)}
${getErrorPopover()} ${getErrorPopover(state)}
<div class='content'>${getView()}</div> <div class='content'>${getView(state)}</div>
${getModal()} ${getModal(state)}
</div> </div>
` `
}
function getErrorPopover () {
var now = new Date().getTime() function getErrorPopover (state) {
var recentErrors = state.errors.filter((x) => now - x.time < 5000) var now = new Date().getTime()
var recentErrors = state.errors.filter((x) => now - x.time < 5000)
var errorElems = recentErrors.map(function (error) {
return hx`<div class='error'>${error.message}</div>` var errorElems = recentErrors.map(function (error) {
}) return hx`<div class='error'>${error.message}</div>`
return hx` })
<div class='error-popover ${recentErrors.length > 0 ? 'visible' : 'hidden'}'> return hx`
<div class='title'>Error</div> <div class='error-popover ${recentErrors.length > 0 ? 'visible' : 'hidden'}'>
${errorElems} <div class='title'>Error</div>
</div> ${errorElems}
` </div>
} `
}
function getModal () {
if (state.modal) { function getModal (state) {
var contents = Modals[state.modal.id](state, dispatch) if (!state.modal) return
return hx` var contents = Modals[state.modal.id](state)
<div class='modal'> return hx`
<div class='modal-background'></div> <div class='modal'>
<div class='modal-content'> <div class='modal-background'></div>
${contents} <div class='modal-content'>
</div> ${contents}
</div> </div>
` </div>
} `
} }
function getView () { function getView (state) {
if (state.location.current().url === 'home') { var url = state.location.current().url
return TorrentList(state, dispatch) return Views[url](state)
} else if (state.location.current().url === 'player') {
return Player(state, dispatch)
}
}
} }

View File

@@ -1,76 +0,0 @@
module.exports = UpdateAvailableModal
var h = require('virtual-dom/h')
var hyperx = require('hyperx')
var hx = hyperx(h)
var path = require('path')
var {dispatch} = require('../lib/dispatcher')
function UpdateAvailableModal (state) {
// First, extract the base folder that the files are all in
var files = state.modal.files
var pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
if (files.length > 0 && !pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
// Then, use the name of the base folder (or sole file, for a single file torrent)
// as the default name. Show all files relative to the base folder.
var defaultName = path.basename(pathPrefix)
var basePath = path.dirname(pathPrefix)
var fileElems = files.map(function (file) {
var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path)
return hx`<div>${relativePath}</div>`
})
return hx`
<div class='create-torrent-modal'>
<p><strong>Create New Torrent</strong></p>
<p class='torrent-attribute'>
<label>Name:</label>
<div class='torrent-attribute'>${defaultName}</div>
</p>
<p class='torrent-attribute'>
<label>Path:</label>
<div class='torrent-attribute'>${pathPrefix}</div>
</p>
<p class='torrent-attribute'>
<label>Files:</label>
<div>${fileElems}</div>
</p>
<p>
<button class='primary' onclick=${handleOK}>Create Torrent</button>
<button class='cancel' onclick=${handleCancel}>Cancel</button>
</p>
</div>
`
function handleOK () {
var options = {
// TODO: we can't let the user choose their own name if we want WebTorrent
// to use the files in place rather than creating a new folder.
// name: document.querySelector('.torrent-name').value
name: defaultName,
path: basePath,
files: files
}
dispatch('createTorrent', options)
dispatch('exitModal')
}
function handleCancel () {
dispatch('exitModal')
}
}
// Finds the longest common prefix
function findCommonPrefix (a, b) {
for (var i = 0; i < a.length && i < b.length; i++) {
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
}
if (i === a.length) return a
if (i === b.length) return b
return a.substring(0, i)
}

View File

@@ -0,0 +1,138 @@
module.exports = CreateTorrentPage
var h = require('virtual-dom/h')
var hyperx = require('hyperx')
var hx = hyperx(h)
var createTorrent = require('create-torrent')
var path = require('path')
var prettyBytes = require('prettier-bytes')
var {dispatch} = require('../lib/dispatcher')
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}))
// First, extract the base folder that the files are all in
var pathPrefix = info.folderPath
if (!pathPrefix) {
if (files.length > 0) {
pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
} else {
pathPrefix = files[0]
}
}
// Sanity check: show the number of files and total size
var numFiles = files.length
console.log('FILES', files)
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 = path.basename(pathPrefix)
var 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`<div>${relativePath}</div>`
})
if (files.length > maxFileElems) {
fileElems.push(hx`<div>+ ${maxFileElems - files.length} more</div>`)
}
var trackers = createTorrent.announceList.join('\n')
var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed'
return hx`
<div class='create-torrent-page'>
<h2>Create torrent ${defaultName}</h2>
<p class="torrent-info">
${torrentInfo}
</p>
<p class='torrent-attribute'>
<label>Path:</label>
<div class='torrent-attribute'>${pathPrefix}</div>
</p>
<div class='expand-collapse ${collapsedClass}' onclick=${handleToggleShowAdvanced}>
${info.showAdvanced ? 'Basic' : 'Advanced'}
</div>
<div class="create-torrent-advanced ${collapsedClass}">
<p class='torrent-attribute'>
<label>Comment:</label>
<textarea class='torrent-attribute torrent-comment'></textarea>
</p>
<p class='torrent-attribute'>
<label>Trackers:</label>
<textarea class='torrent-attribute torrent-trackers'>${trackers}</textarea>
</p>
<p class='torrent-attribute'>
<label>Private:</label>
<input type='checkbox' class='torrent-is-private' value='torrent-is-private'>
</p>
<p class='torrent-attribute'>
<label>Files:</label>
<div>${fileElems}</div>
</p>
</div>
<p class="float-right">
<button class='button-flat light' onclick=${handleCancel}>Cancel</button>
<button class='button-raised' onclick=${handleOK}>Create Torrent</button>
</p>
</div>
`
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)
dispatch('backToList')
}
function handleCancel () {
dispatch('backToList')
}
function handleToggleShowAdvanced () {
// TODO: what's the clean way to handle this?
// Should every button on every screen have its own dispatch()?
info.showAdvanced = !info.showAdvanced
dispatch('update')
}
}
// Finds the longest common prefix
function findCommonPrefix (a, b) {
for (var i = 0; i < a.length && i < b.length; i++) {
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
}
if (i === a.length) return a
if (i === b.length) return b
return a.substring(0, i)
}

View File

@@ -9,12 +9,15 @@ var {dispatch} = require('../lib/dispatcher')
function OpenTorrentAddressModal (state) { function OpenTorrentAddressModal (state) {
return hx` return hx`
<div class='open-torrent-address-modal'> <div class='open-torrent-address-modal'>
<p><strong>Enter torrent address or magnet link</strong></p> <p><label>Enter torrent address or magnet link</label></p>
<p> <p>
<input id='add-torrent-url' type='text' autofocus onkeypress=${handleKeyPress} /> <input id='add-torrent-url' type='text' onkeypress=${handleKeyPress} />
<button class='primary' onclick=${handleOK}>OK</button>
<button class='cancel' onclick=${handleCancel}>Cancel</button>
</p> </p>
<p class='float-right'>
<button class='button button-flat' onclick=${handleCancel}>CANCEL</button>
<button class='button button-raised' onclick=${handleOK}>OK</button>
</p>
<script>document.querySelector('#add-torrent-url').focus()</script>
</div> </div>
` `
} }

View File

@@ -4,10 +4,11 @@ var h = require('virtual-dom/h')
var hyperx = require('hyperx') var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
var WebChimeraPlayer = require('wcjs-player')
var prettyBytes = require('prettier-bytes') var prettyBytes = require('prettier-bytes')
var Bitfield = require('bitfield') var Bitfield = require('bitfield')
var util = require('../util') var TorrentSummary = require('../lib/torrent-summary')
var {dispatch, dispatcher} = require('../lib/dispatcher') var {dispatch, dispatcher} = require('../lib/dispatcher')
// Shows a streaming video player. Standard features + Chromecast + Airplay // Shows a streaming video player. Standard features + Chromecast + Airplay
@@ -27,11 +28,17 @@ function Player (state) {
function renderMedia (state) { function renderMedia (state) {
if (!state.server) return if (!state.server) return
if (false) return renderMediaTag(state)
else return renderMediaVLC(state)
}
// Renders using a <video> or <audio> tag
// Handles only a subset of codecs, but it's cleaner and more efficient
// See renderMediaVLC()
function renderMediaTag (state) {
// Unfortunately, play/pause can't be done just by modifying HTML. // Unfortunately, play/pause can't be done just by modifying HTML.
// Instead, grab the DOM node and play/pause it if necessary // Instead, grab the DOM node and play/pause it if necessary
var mediaType = state.playing.type /* 'audio' or 'video' */ var mediaElement = document.querySelector(state.playing.type) /* get the <video> or <audio> tag */
var mediaElement = document.querySelector(mediaType) /* get the <video> or <audio> tag */
if (mediaElement !== null) { if (mediaElement !== null) {
if (state.playing.isPaused && !mediaElement.paused) { if (state.playing.isPaused && !mediaElement.paused) {
mediaElement.pause() mediaElement.pause()
@@ -49,11 +56,36 @@ function renderMedia (state) {
state.playing.setVolume = null state.playing.setVolume = null
} }
// fix textTrack cues not been removed <track> rerender
if (state.playing.subtitles.change) {
var tracks = mediaElement.textTracks
for (var j = 0; j < tracks.length; j++) {
// mode is not an <track> attribute, only available on DOM
tracks[j].mode = (tracks[j].label === state.playing.subtitles.change) ? 'showing' : 'hidden'
}
state.playing.subtitles.change = null
}
state.playing.currentTime = mediaElement.currentTime state.playing.currentTime = mediaElement.currentTime
state.playing.duration = mediaElement.duration state.playing.duration = mediaElement.duration
state.playing.volume = mediaElement.volume state.playing.volume = mediaElement.volume
} }
// Add subtitles to the <video> tag
var trackTags = []
if (state.playing.subtitles.enabled && state.playing.subtitles.tracks.length > 0) {
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) {
var track = state.playing.subtitles.tracks[i]
trackTags.push(hx`
<track
${track.selected ? 'default' : ''}
label=${track.label}
type='subtitles'
src=${track.buffer}>
`)
}
}
// Create the <audio> or <video> tag // Create the <audio> or <video> tag
var mediaTag = hx` var mediaTag = hx`
<div <div
@@ -61,14 +93,111 @@ function renderMedia (state) {
ondblclick=${dispatcher('toggleFullScreen')} ondblclick=${dispatcher('toggleFullScreen')}
onloadedmetadata=${onLoadedMetadata} onloadedmetadata=${onLoadedMetadata}
onended=${onEnded} onended=${onEnded}
onplay=${dispatcher('mediaPlaying')}
onpause=${dispatcher('mediaPaused')}
onstalling=${dispatcher('mediaStalled')} onstalling=${dispatcher('mediaStalled')}
onerror=${dispatcher('mediaError')}
ontimeupdate=${dispatcher('mediaTimeUpdate')} ontimeupdate=${dispatcher('mediaTimeUpdate')}
autoplay> autoplay>
${trackTags}
</div>
`
mediaTag.tagName = state.playing.type // conditional tag name
// Show the media.
return hx`
<div
class='letterbox'
onmousemove=${dispatcher('mediaMouseMoved')}>
${mediaTag}
${renderOverlay(state)}
</div>
`
// As soon as the video loads enough to know the video dimensions, resize the window
function onLoadedMetadata (e) {
if (state.playing.type !== 'video') return
var video = e.target
var dimensions = {
width: video.videoWidth,
height: video.videoHeight
}
dispatch('setDimensions', dimensions)
}
// When the video completes, pause the video instead of looping
function onEnded (e) {
state.playing.isPaused = true
}
}
// Renders using WebChimera.js to render using VLC
// That lets us play media that the <video> tag can't play
function renderMediaVLC (state) {
// Unfortunately, WebChimera can't be done just by modifying HTML.
// Instead, grab the DOM node
if (document.querySelector('#media-player')) {
if (!state.playing.chimera) {
state.playing.chimera = new WebChimeraPlayer('#media-player')
.addPlayer({
autoplay: true,
vlcArgs: ['-vvv'],
wcjsRendererOptions: {'fallbackRenderer': true}
})
.onPlaying(dispatcher('mediaPlaying'))
.onPaused(dispatcher('mediaPaused'))
.onBuffering(dispatcher('mediaStalled'))
.onTime(dispatcher('mediaTimeUpdate'))
.onEnded(onEnded)
.onFrameSetup(onLoadedMetadata)
.addPlaylist(state.server.localURL)
state.playing.chimera.ui(false)
} else {
var player = state.playing.chimera
if (state.playing.isPaused && player.playing()) {
player.pause()
} else if (!state.playing.isPaused && !player.playing()) {
player.play()
}
// When the user clicks or drags on the progress bar, jump to that position
if (state.playing.jumpToTime) {
player.time(state.playing.jumpToTime * 1000) // WebChimera expects milliseconds
state.playing.jumpToTime = null
}
// Set volume
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
player.volume(Math.round(state.playing.setVolume * 100)) // WebChimera expects integer percent
state.playing.setVolume = null
}
state.playing.currentTime = player.time() / 1000
state.playing.duration = player.length() / 1000
state.playing.volume = player.volume() / 100
}
} else {
state.playing.chimera = null
}
// Add subtitles to the <video> tag
var trackTags = []
if (state.playing.subtitles.enabled && state.playing.subtitles.tracks.length > 0) {
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) {
var track = state.playing.subtitles.tracks[i]
trackTags.push(hx`
<track
default=${track.selected ? 'default' : ''}
label=${track.language}
type='subtitles'
src=${track.buffer}>
`)
}
}
// Create the <audio> or <video> tag
var mediaType = state.playing.type /* 'video' or 'audio' */
var mediaTag = hx`
<div id='media-player' class='${mediaType}-player'>
${trackTags}
</div> </div>
` `
mediaTag.tagName = mediaType
// Show the media. // Show the media.
return hx` return hx`
@@ -83,10 +212,9 @@ function renderMedia (state) {
// As soon as the video loads enough to know the video dimensions, resize the window // As soon as the video loads enough to know the video dimensions, resize the window
function onLoadedMetadata (e) { function onLoadedMetadata (e) {
if (mediaType !== 'video') return if (mediaType !== 'video') return
var video = e.target
var dimensions = { var dimensions = {
width: video.videoWidth, width: player.width(),
height: video.videoHeight height: player.height()
} }
dispatch('setDimensions', dimensions) dispatch('setDimensions', dimensions)
} }
@@ -213,9 +341,27 @@ function renderCastScreen (state) {
` `
} }
function renderSubtitlesOptions (state) {
var subtitles = state.playing.subtitles
if (subtitles.tracks.length && subtitles.show) {
return hx`<ul.subtitles-list>
${subtitles.tracks.map(function (w, i) {
return hx`<li onclick=${dispatcher('selectSubtitle', w.label)}><i.icon>${w.selected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>${w.label}</li>`
})}
<li onclick=${dispatcher('selectSubtitle', '')}><i.icon>${!subtitles.enabled ? 'radio_button_checked' : 'radio_button_unchecked'}</i>None</li>
</ul>
`
}
}
function renderPlayerControls (state) { function renderPlayerControls (state) {
var positionPercent = 100 * state.playing.currentTime / state.playing.duration var positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' }
var captionsClass = state.playing.subtitles.tracks.length === 0
? 'disabled'
: state.playing.subtitles.enabled
? 'active'
: ''
var elements = [ var elements = [
hx` hx`
@@ -236,6 +382,17 @@ function renderPlayerControls (state) {
` `
] ]
if (state.playing.type === 'video') {
// show closed captions icon
elements.push(hx`
<i.icon.closed-captions
class=${captionsClass}
onclick=${handleSubtitles}>
closed_captions
</i>
`)
}
// If we've detected a Chromecast or AppleTV, the user can play video there // If we've detected a Chromecast or AppleTV, the user can play video there
var isOnChromecast = state.playing.location.startsWith('chromecast') var isOnChromecast = state.playing.location.startsWith('chromecast')
var isOnAirplay = state.playing.location.startsWith('airplay') var isOnAirplay = state.playing.location.startsWith('airplay')
@@ -310,6 +467,31 @@ function renderPlayerControls (state) {
`) `)
} }
// render volume
var volume = state.playing.volume
var volumeIcon = 'volume_' + (volume === 0 ? 'off' : volume < 0.3 ? 'mute' : volume < 0.6 ? 'down' : 'up')
var volumeStyle = { background: '-webkit-gradient(linear, left top, right top, ' +
'color-stop(' + (volume * 100) + '%, #eee), ' +
'color-stop(' + (volume * 100) + '%, #727272))'
}
elements.push(hx`
<div.volume
onwheel=${handleVolumeWheel}>
<i.icon.volume-icon onmousedown=${handleVolumeMute}>
${volumeIcon}
</i>
<input.volume-slider
type='range' min='0' max='1' step='0.05' value=${volumeChanging !== false ? volumeChanging : volume}
onmousedown=${handleVolumeScrub}
onmouseup=${handleVolumeScrub}
onmousemove=${handleVolumeScrub}
onwheel=${handleVolumeWheel}
style=${volumeStyle}
/>
</div>
`)
// Finally, the big button in the center plays or pauses the video // Finally, the big button in the center plays or pauses the video
elements.push(hx` elements.push(hx`
<i class='icon play-pause' onclick=${dispatcher('playPause')}> <i class='icon play-pause' onclick=${dispatcher('playPause')}>
@@ -317,7 +499,12 @@ function renderPlayerControls (state) {
</i> </i>
`) `)
return hx`<div class='player-controls'>${elements}</div>` return hx`
<div class='player-controls'>
${elements}
${renderSubtitlesOptions(state)}
</div>
`
// Handles a click or drag to scrub (jump to another position in the video) // Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) { function handleScrub (e) {
@@ -327,8 +514,53 @@ function renderPlayerControls (state) {
var position = fraction * state.playing.duration /* seconds */ var position = fraction * state.playing.duration /* seconds */
dispatch('playbackJump', position) dispatch('playbackJump', position)
} }
// Handles volume change by wheel
function handleVolumeWheel (e) {
dispatch('changeVolume', (-e.deltaY | e.deltaX) / 500)
}
// Handles volume muting and Unmuting
function handleVolumeMute (e) {
if (state.playing.volume === 0.0) {
dispatch('setVolume', 1.0)
} else {
dispatch('setVolume', 0.0)
}
}
// Handles volume slider scrub
function handleVolumeScrub (e) {
switch (e.type) {
case 'mouseup':
volumeChanging = false
dispatch('setVolume', e.offsetX / 50)
break
case 'mousedown':
volumeChanging = this.value
break
case 'mousemove':
// only change if move was started by click
if (volumeChanging !== false) {
volumeChanging = this.value
}
break
}
}
function handleSubtitles (e) {
if (!state.playing.subtitles.tracks.length || e.ctrlKey || e.metaKey) {
// if no subtitles available select it
dispatch('openSubtitles')
} else {
dispatch('showSubtitles')
}
}
} }
// lets scrub without sending to volume backend
var volumeChanging = false
// Renders the loading bar. Shows which parts of the torrent are loaded, which // Renders the loading bar. Shows which parts of the torrent are loaded, which
// can be "spongey" / non-contiguous // can be "spongey" / non-contiguous
function renderLoadingBar (state) { function renderLoadingBar (state) {
@@ -370,10 +602,9 @@ function renderLoadingBar (state) {
// Returns the CSS background-image string for a poster image + dark vignette // Returns the CSS background-image string for a poster image + dark vignette
function cssBackgroundImagePoster (state) { function cssBackgroundImagePoster (state) {
var torrentSummary = getPlayingTorrentSummary(state) var torrentSummary = getPlayingTorrentSummary(state)
if (!torrentSummary || !torrentSummary.posterURL) return '' var posterPath = TorrentSummary.getPosterPath(torrentSummary)
var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL) if (!posterPath) return ''
var cleanURL = posterURL.replace(/\\/g, '/') return cssBackgroundImageDarkGradient() + `, url(${posterPath})`
return cssBackgroundImageDarkGradient() + `, url(${cleanURL})`
} }
function cssBackgroundImageDarkGradient () { function cssBackgroundImageDarkGradient () {

View File

@@ -5,8 +5,7 @@ var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
var prettyBytes = require('prettier-bytes') var prettyBytes = require('prettier-bytes')
var util = require('../util') var TorrentSummary = require('../lib/torrent-summary')
var TorrentPlayer = require('../lib/torrent-player') var TorrentPlayer = require('../lib/torrent-player')
var {dispatcher} = require('../lib/dispatcher') var {dispatcher} = require('../lib/dispatcher')
@@ -31,15 +30,12 @@ function TorrentList (state) {
// Background image: show some nice visuals, like a frame from the movie, if possible // Background image: show some nice visuals, like a frame from the movie, if possible
var style = {} var style = {}
if (torrentSummary.posterURL) { if (torrentSummary.posterFileName) {
var gradient = isSelected var gradient = isSelected
? 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 100%)' ? 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 100%)'
: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)' : 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)'
var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL) var posterPath = TorrentSummary.getPosterPath(torrentSummary)
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron): style.backgroundImage = gradient + `, url('${posterPath}')`
// Backslashes in URLS in CSS cause bizarre string encoding issues
var cleanURL = posterURL.replace(/\\/g, '/')
style.backgroundImage = gradient + `, url('${cleanURL}')`
} }
// Foreground: name of the torrent, basic info like size, play button, // Foreground: name of the torrent, basic info like size, play button,
@@ -141,7 +137,7 @@ function TorrentList (state) {
var playButton var playButton
if (TorrentPlayer.isPlayableTorrent(torrentSummary)) { if (TorrentPlayer.isPlayableTorrent(torrentSummary)) {
playButton = hx` playButton = hx`
<i.btn.icon.play <i.button-round.icon.play
title=${playTooltip} title=${playTooltip}
class=${playClass} class=${playClass}
onclick=${dispatcher('play', infoHash)}> onclick=${dispatcher('play', infoHash)}>
@@ -153,7 +149,7 @@ function TorrentList (state) {
return hx` return hx`
<div class='buttons'> <div class='buttons'>
${playButton} ${playButton}
<i.btn.icon.download <i.button-round.icon.download
class=${torrentSummary.status} class=${torrentSummary.status}
title=${downloadTooltip} title=${downloadTooltip}
onclick=${dispatcher('toggleTorrent', infoHash)}> onclick=${dispatcher('toggleTorrent', infoHash)}>

View File

@@ -6,8 +6,7 @@ var WebTorrent = require('webtorrent')
var defaultAnnounceList = require('create-torrent').announceList var defaultAnnounceList = require('create-torrent').announceList
var deepEqual = require('deep-equal') var deepEqual = require('deep-equal')
var electron = require('electron') var electron = require('electron')
var fs = require('fs') var fs = require('fs-extra')
var mkdirp = require('mkdirp')
var musicmetadata = require('musicmetadata') var musicmetadata = require('musicmetadata')
var networkAddress = require('network-address') var networkAddress = require('network-address')
var path = require('path') var path = require('path')
@@ -69,10 +68,21 @@ function init () {
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent- // See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
function startTorrenting (torrentKey, torrentID, path, fileModtimes) { function startTorrenting (torrentKey, torrentID, path, fileModtimes) {
console.log('starting torrent %s: %s', torrentKey, torrentID) console.log('starting torrent %s: %s', torrentKey, torrentID)
var torrent = client.add(torrentID, { var torrent
path: path, try {
fileModtimes: fileModtimes torrent = client.add(torrentID, {
}) path: path,
fileModtimes: fileModtimes
})
} catch (err) {
return ipc.send('wt-error', torrentKey, err.message)
}
// If we add a duplicate magnet URI or infohash, WebTorrent returns the
// existing torrent object! (If we add a duplicate torrent file, it creates a
// new torrent object and raises an error later.) Workaround:
if (torrent.key) {
return ipc.send('wt-error', torrentKey, 'Can\'t add duplicate torrent')
}
torrent.key = torrentKey torrent.key = torrentKey
addTorrentEvents(torrent) addTorrentEvents(torrent)
return torrent return torrent
@@ -85,8 +95,9 @@ function stopTorrenting (infoHash) {
// Create a new torrent, start seeding // Create a new torrent, start seeding
function createTorrent (torrentKey, options) { function createTorrent (torrentKey, options) {
console.log('creating torrent %s', torrentKey, options) console.log('creating torrent', torrentKey, options)
var torrent = client.seed(options.files, options) var paths = options.files.map((f) => f.path)
var torrent = client.seed(paths, options)
torrent.key = torrentKey torrent.key = torrentKey
addTorrentEvents(torrent) addTorrentEvents(torrent)
ipc.send('wt-new-torrent') ipc.send('wt-new-torrent')
@@ -159,9 +170,10 @@ function getTorrentFileInfo (file) {
function saveTorrentFile (torrentKey) { function saveTorrentFile (torrentKey) {
var torrent = getTorrent(torrentKey) var torrent = getTorrent(torrentKey)
checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) { checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) {
var fileName = torrent.infoHash + '.torrent'
if (exists) { if (exists) {
// We've already saved the file // We've already saved the file
return ipc.send('wt-file-saved', torrentKey, torrentPath) return ipc.send('wt-file-saved', torrentKey, fileName)
} }
// Otherwise, save the .torrent file, under the app config folder // Otherwise, save the .torrent file, under the app config folder
@@ -169,7 +181,7 @@ function saveTorrentFile (torrentKey) {
fs.writeFile(torrentPath, torrent.torrentFile, function (err) { fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err) if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
console.log('saved torrent file %s', torrentPath) console.log('saved torrent file %s', torrentPath)
return ipc.send('wt-file-saved', torrentKey, torrentPath) return ipc.send('wt-file-saved', torrentKey, fileName)
}) })
}) })
}) })
@@ -191,13 +203,14 @@ function generateTorrentPoster (torrentKey) {
torrentPoster(torrent, function (err, buf, extension) { torrentPoster(torrent, function (err, buf, extension) {
if (err) return console.log('error generating poster: %o', err) if (err) return console.log('error generating poster: %o', err)
// save it for next time // save it for next time
mkdirp(config.CONFIG_POSTER_PATH, function (err) { fs.mkdirp(config.CONFIG_POSTER_PATH, function (err) {
if (err) return console.log('error creating poster dir: %o', err) if (err) return console.log('error creating poster dir: %o', err)
var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension) var posterFileName = torrent.infoHash + extension
var posterFilePath = path.join(config.CONFIG_POSTER_PATH, posterFileName)
fs.writeFile(posterFilePath, buf, function (err) { fs.writeFile(posterFilePath, buf, function (err) {
if (err) return console.log('error saving poster: %o', err) if (err) return console.log('error saving poster: %o', err)
// show the poster // show the poster
ipc.send('wt-poster', torrentKey, posterFilePath) ipc.send('wt-poster', torrentKey, posterFileName)
}) })
}) })
}) })

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

BIN
static/wired-cd.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
static/wired-cd.torrent Normal file

Binary file not shown.