Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d61968f64 | ||
|
|
8637de27b9 | ||
|
|
447413e4b9 | ||
|
|
82c8ad7562 | ||
|
|
f2bbd97eeb | ||
|
|
c788b3358a | ||
|
|
b0672cce9e | ||
|
|
0681169653 | ||
|
|
ae6b86d233 | ||
|
|
8bba565609 | ||
|
|
78f08487c4 | ||
|
|
bdf7110135 | ||
|
|
9d35ece954 | ||
|
|
00e4cc1864 | ||
|
|
ad09012587 | ||
|
|
8b5de572f1 | ||
|
|
20c6b81047 | ||
|
|
aecead4a2d | ||
|
|
7b02edca0f | ||
|
|
109094d0e1 | ||
|
|
e0856a5274 | ||
|
|
c8886fb606 | ||
|
|
fd5f4dd139 | ||
|
|
0a51da13a4 | ||
|
|
5540ed9ce1 | ||
|
|
b6516dc40f | ||
|
|
8b57e13735 | ||
|
|
cb3dd716dd | ||
|
|
4895fb930c | ||
|
|
1f2985bbc3 | ||
|
|
32ad0f0926 | ||
|
|
3e448da0ba | ||
|
|
219e717021 | ||
|
|
1885b6a89e | ||
|
|
d8a5b8a701 | ||
|
|
d41e08b209 | ||
|
|
9518670c7b | ||
|
|
eff0b6eb23 | ||
|
|
f56af6402c | ||
|
|
ebcc814ca7 | ||
|
|
f7029c811c | ||
|
|
a578d7555f | ||
|
|
dff969f955 | ||
|
|
b964240a20 | ||
|
|
7f7a395d67 | ||
|
|
475ef8c6d0 | ||
|
|
e269489639 | ||
|
|
20d5d5d60d | ||
|
|
590dc99c51 | ||
|
|
10e9b36aea | ||
|
|
5250f55bf7 |
48
CHANGELOG.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# WebTorrent.app Version History
|
||||
|
||||
## v0.1.0
|
||||
|
||||
- **Windows support!**
|
||||
- See `WebTorrentSetup.exe` in the downloads below!
|
||||
- Auto-updater included, just like the OS X version.
|
||||
- Automatically installs desktop/start menu shortcuts
|
||||
- Windows top menu is no longer automatically hidden.
|
||||
- **Audio file support!**
|
||||
- Supports playback of .mp3, .aac, .ogg, .wav
|
||||
- Audio file metadata gets shown in the UI
|
||||
- Focus the WebTorrent window after opening magnet link in third-party app
|
||||
- Subtler app sounds
|
||||
- Fix for some magnet links failing to open
|
||||
|
||||
Thanks to @dcposch, @ngoldman, and @feross for contributing to this release.
|
||||
|
||||
## v0.0.1
|
||||
|
||||
- Wait 10 seconds (instead of 60 seconds) after app launch before checking for updates.
|
||||
|
||||
## v0.0.0
|
||||
|
||||
The first official release of WebTorrent.app, the streaming torrent client for OS X,
|
||||
Windows, and Linux. For now, we're only releasing binaries for OS X.
|
||||
|
||||
WebTorrent.app is in ALPHA and under very active development – expect lots more polish in
|
||||
the coming weeks! If you know JavaScript and want to help us out, there's
|
||||
[lots to do](https://github.com/feross/webtorrent-app/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
|
||||
|
||||
### Features
|
||||
|
||||
- **Lightweight, fast torrent client**
|
||||
- **Beautiful user experience**
|
||||
- **Instantly stream video and audio** from torrents!
|
||||
- WebTorrent fetches file pieces from the network **on-demand**, for instant playback.
|
||||
- Even when the file is not fully downloaded, **seeking still works!** (Seeking just reprioritizes what pieces are fetched from the network.)
|
||||
- Stream videos to **AirPlay** and **Chromecast**
|
||||
- **Pure Javascript**, so it's very easy to contribute code!
|
||||
- Based on the most popular and comprehensive torrent package in Node.js, [`webtorrent`](https://www.npmjs.com/package/webtorrent).
|
||||
- Lots of **features**, without the bloat:
|
||||
- Opens magnet links and .torrent files
|
||||
- Drag-and-drop makes adding torrents easy!
|
||||
- Seed files/folders by dragging them onto the app
|
||||
- Discovers peers via tracker servers, DHT (Distributed Hash Table), and peer exchange
|
||||
- Make the video window "float on top" for watching video while you work!
|
||||
- Supports WebTorrent protocol – for connecting to WebRTC peers (i.e. web browsers)
|
||||
18
README.md
@@ -22,7 +22,7 @@
|
||||
|
||||
## Install
|
||||
|
||||
**WebTorrent.app** is still under very active development. Expect a release very soon!
|
||||
**WebTorrent.app** is still under very active development. An [alpha release](https://github.com/feross/webtorrent-app/releases) is currently available for OS X.
|
||||
|
||||
## Screenshot
|
||||
|
||||
@@ -58,10 +58,20 @@ To build for one platform:
|
||||
$ npm run package -- [platform]
|
||||
```
|
||||
|
||||
Where `[platform]` is `--darwin`, `--linux`, or `--win32`.
|
||||
Where `[platform]` is `darwin`, `linux`, or `win32`.
|
||||
|
||||
To package a Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs
|
||||
to be installed. On OS X, it is installable via [Homebrew](http://brew.sh/).
|
||||
#### Windows build notes
|
||||
|
||||
To package the Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs
|
||||
to be installed.
|
||||
|
||||
On OS X, first install [XQuartz](http://www.xquartz.org/), then run:
|
||||
|
||||
```
|
||||
brew install wine
|
||||
```
|
||||
|
||||
(Requires the [Homebrew](http://brew.sh/) package manager.)
|
||||
|
||||
### Code Style
|
||||
|
||||
|
||||
120
bin/package.js
@@ -15,11 +15,11 @@ var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
|
||||
|
||||
function build () {
|
||||
var platform = process.argv[2]
|
||||
if (platform === '--darwin') {
|
||||
if (platform === 'darwin') {
|
||||
buildDarwin(printDone)
|
||||
} else if (platform === '--win32') {
|
||||
} else if (platform === 'win32') {
|
||||
buildWin32(printDone)
|
||||
} else if (platform === '--linux') {
|
||||
} else if (platform === 'linux') {
|
||||
buildLinux(printDone)
|
||||
} else {
|
||||
buildDarwin(function (err, buildPath) {
|
||||
@@ -59,7 +59,7 @@ var all = {
|
||||
|
||||
// Pattern which specifies which files to ignore when copying files to create the
|
||||
// package(s).
|
||||
ignore: /^\/dist|\/(appveyor.yml|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^\/]*|.*\.md|.*\.markdown)$/,
|
||||
ignore: /^\/dist|\/(appveyor.yml|.appveyor.yml|appdmg|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^\/]*|.*\.md|.*\.markdown)$/,
|
||||
|
||||
// The application name.
|
||||
name: config.APP_NAME,
|
||||
@@ -82,14 +82,14 @@ var darwin = {
|
||||
platform: 'darwin',
|
||||
|
||||
// The bundle identifier to use in the application's plist (OS X only).
|
||||
'app-bundle-id': 'io.webtorrent.app',
|
||||
'app-bundle-id': 'io.webtorrent.webtorrent',
|
||||
|
||||
// The application category type, as shown in the Finder via "View" -> "Arrange by
|
||||
// Application Category" when viewing the Applications directory (OS X only).
|
||||
'app-category-type': 'public.app-category.utilities',
|
||||
|
||||
// The bundle identifier to use in the application helper's plist (OS X only).
|
||||
'helper-bundle-id': 'io.webtorrent.app.helper',
|
||||
'helper-bundle-id': 'io.webtorrent.webtorrent-helper',
|
||||
|
||||
// Application icon.
|
||||
icon: config.APP_ICON + '.icns'
|
||||
@@ -137,9 +137,7 @@ var linux = {
|
||||
build()
|
||||
|
||||
function buildDarwin (cb) {
|
||||
var appDmg = require('appdmg')
|
||||
var plist = require('plist')
|
||||
var sign = require('electron-osx-sign')
|
||||
|
||||
electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
@@ -180,56 +178,70 @@ function buildDarwin (cb) {
|
||||
infoPlist.NSHumanReadableCopyright = config.APP_COPYRIGHT
|
||||
|
||||
fs.writeFileSync(infoPlistPath, plist.build(infoPlist))
|
||||
|
||||
// Copy torrent file icon into app bundle
|
||||
cp.execSync(`cp ${config.APP_FILE_ICON + '.icns'} ${resourcesPath}`)
|
||||
|
||||
var zipPath = path.join(buildPath[0], BUILD_NAME + '.zip')
|
||||
cp.execSync(`zip -r -y ${zipPath} ${appPath}`)
|
||||
|
||||
/*
|
||||
* Signing OS X apps for distribution outside the App Store requires:
|
||||
*
|
||||
* - Xcode
|
||||
* - Xcode Command Line Tools (xcode-select --install)
|
||||
* - Membership in the Apple Developer Program
|
||||
*/
|
||||
if (process.platform === 'darwin') {
|
||||
var appDmg = require('appdmg')
|
||||
var sign = require('electron-osx-sign')
|
||||
|
||||
/*
|
||||
* Sign the app with Apple Developer ID certificate. We sign the app for 2 reasons:
|
||||
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
|
||||
* the same author as the current version.
|
||||
* - So users will not a see a warning about the app coming from an "Unidentified
|
||||
* Developer" when they open it for the first time (OS X Gatekeeper).
|
||||
*
|
||||
* To sign an OS X app for distribution outside the App Store, the following are
|
||||
* required:
|
||||
* - Xcode
|
||||
* - Xcode Command Line Tools (xcode-select --install)
|
||||
* - Membership in the Apple Developer Program
|
||||
*/
|
||||
var signOpts = {
|
||||
app: appPath,
|
||||
platform: 'darwin',
|
||||
verbose: true
|
||||
}
|
||||
|
||||
var dmgPath = path.join(buildPath[0], BUILD_NAME + '.dmg')
|
||||
var dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: dmgPath,
|
||||
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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
sign(signOpts, function (err) {
|
||||
if (err) return cb(err)
|
||||
|
||||
// Create .zip file (used by the auto-updater)
|
||||
var zipPath = path.join(config.ROOT_PATH, 'dist', BUILD_NAME + '.zip')
|
||||
cp.execSync(`pushd ${buildPath[0]} && zip -r -y ${zipPath} ${config.APP_NAME + '.app'} && popd`)
|
||||
console.log('Created OS X .zip file.')
|
||||
|
||||
// Create a .dmg (OS X disk image) file, for easy user installation.
|
||||
var dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: path.join(config.ROOT_PATH, 'dist', BUILD_NAME + '.dmg'),
|
||||
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)
|
||||
dmg.on('error', cb)
|
||||
dmg.on('progress', function (info) {
|
||||
if (info.type === 'step-begin') console.log(info.title + '...')
|
||||
})
|
||||
dmg.on('finish', function (info) {
|
||||
console.log('Created OS X disk image (.dmg) file.')
|
||||
cb(null, buildPath)
|
||||
})
|
||||
})
|
||||
@@ -238,7 +250,33 @@ function buildDarwin (cb) {
|
||||
}
|
||||
|
||||
function buildWin32 (cb) {
|
||||
electronPackager(Object.assign({}, all, win32), cb)
|
||||
var installer = require('electron-winstaller')
|
||||
|
||||
electronPackager(Object.assign({}, all, win32), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
|
||||
console.log('Creating Windows installer...')
|
||||
installer.createWindowsInstaller({
|
||||
name: config.APP_NAME,
|
||||
productName: config.APP_NAME,
|
||||
title: config.APP_NAME,
|
||||
exe: config.APP_NAME + '.exe',
|
||||
|
||||
appDirectory: buildPath[0],
|
||||
outputDirectory: path.join(config.ROOT_PATH, 'dist'),
|
||||
version: pkg.version,
|
||||
description: config.APP_NAME,
|
||||
authors: config.APP_TEAM,
|
||||
iconUrl: config.APP_ICON + '.ico',
|
||||
setupIcon: config.APP_ICON + '.ico',
|
||||
// certificateFile: '', // TODO
|
||||
usePackageJson: false,
|
||||
loadingGif: path.join(config.STATIC_PATH, 'loading.gif')
|
||||
}).then(function () {
|
||||
console.log('Created Windows installer.')
|
||||
cb(null, buildPath)
|
||||
}).catch(cb)
|
||||
})
|
||||
}
|
||||
|
||||
function buildLinux (cb) {
|
||||
|
||||
8
bin/release-_post.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
git diff --exit-code
|
||||
npm run package
|
||||
git push
|
||||
git push --tags
|
||||
gh-release
|
||||
9
bin/release-_pre.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
git pull
|
||||
npm run update-authors
|
||||
git diff --exit-code
|
||||
rm -rf node_modules/
|
||||
npm install
|
||||
npm test
|
||||
7
bin/release-major.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
BIN=`dirname $0`
|
||||
|
||||
$BIN/release-_pre.sh
|
||||
npm version major
|
||||
$BIN/release-_post.sh
|
||||
7
bin/release-minor.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
BIN=`dirname $0`
|
||||
|
||||
$BIN/release-_pre.sh
|
||||
npm version minor
|
||||
$BIN/release-_post.sh
|
||||
7
bin/release-patch.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
BIN=`dirname $0`
|
||||
|
||||
$BIN/release-_pre.sh
|
||||
npm version patch
|
||||
$BIN/release-_post.sh
|
||||
@@ -10,6 +10,7 @@ while (<>) {
|
||||
next if $seen{$_};
|
||||
next if /<support\@greenkeeper.io>/;
|
||||
next if /<ungoldman\@gmail.com>/;
|
||||
next if /<grunjol\@users.noreply.github.com>/;
|
||||
$seen{$_} = push @authors, "- ", $_;
|
||||
}
|
||||
END {
|
||||
|
||||
46
config.js
@@ -2,17 +2,19 @@ var applicationConfigPath = require('application-config-path')
|
||||
var path = require('path')
|
||||
|
||||
var APP_NAME = 'WebTorrent'
|
||||
var APP_TEAM = 'The WebTorrent Project'
|
||||
var APP_VERSION = require('./package.json').version
|
||||
|
||||
module.exports = {
|
||||
APP_COPYRIGHT: 'Copyright © 2014-2016 The WebTorrent Project',
|
||||
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
|
||||
APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'),
|
||||
APP_ICON: path.join(__dirname, 'static', 'WebTorrent'),
|
||||
APP_NAME: APP_NAME,
|
||||
APP_TEAM: APP_TEAM,
|
||||
APP_VERSION: APP_VERSION,
|
||||
|
||||
AUTO_UPDATE_URL: 'https://webtorrent.io/app/update?version=' + APP_VERSION,
|
||||
AUTO_UPDATE_CHECK_STARTUP_DELAY: 60 * 1000 /* 1 minute */,
|
||||
AUTO_UPDATE_CHECK_STARTUP_DELAY: 10 * 1000 /* 10 seconds */,
|
||||
|
||||
CONFIG_PATH: applicationConfigPath(APP_NAME),
|
||||
CONFIG_POSTER_PATH: path.join(applicationConfigPath(APP_NAME), 'Posters'),
|
||||
@@ -25,14 +27,38 @@ module.exports = {
|
||||
ROOT_PATH: __dirname,
|
||||
STATIC_PATH: path.join(__dirname, 'static'),
|
||||
|
||||
SOUND_ADD: 'file://' + path.join(__dirname, 'static', 'sound', 'add.wav'),
|
||||
SOUND_DELETE: 'file://' + path.join(__dirname, 'static', 'sound', 'delete.wav'),
|
||||
SOUND_DISABLE: 'file://' + path.join(__dirname, 'static', 'sound', 'disable.wav'),
|
||||
SOUND_DONE: 'file://' + path.join(__dirname, 'static', 'sound', 'done.wav'),
|
||||
SOUND_ENABLE: 'file://' + path.join(__dirname, 'static', 'sound', 'enable.wav'),
|
||||
SOUND_ERROR: 'file://' + path.join(__dirname, 'static', 'sound', 'error.wav'),
|
||||
SOUND_PLAY: 'file://' + path.join(__dirname, 'static', 'sound', 'play.wav'),
|
||||
SOUND_STARTUP: 'file://' + path.join(__dirname, 'static', 'sound', 'startup.wav')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function isProduction () {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var log = require('./log')
|
||||
|
||||
module.exports = function () {
|
||||
function init () {
|
||||
if (process.platform === 'win32') {
|
||||
var path = require('path')
|
||||
var iconPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico')
|
||||
162
main/index.js
@@ -4,94 +4,112 @@ var app = electron.app
|
||||
|
||||
var autoUpdater = require('./auto-updater')
|
||||
var config = require('../config')
|
||||
var handlers = require('./handlers')
|
||||
var ipc = require('./ipc')
|
||||
var log = require('./log')
|
||||
var menu = require('./menu')
|
||||
var registerProtocolHandler = require('./register-handlers')
|
||||
var shortcuts = require('./shortcuts')
|
||||
var squirrelWin32 = require('./squirrel-win32')
|
||||
var windows = require('./windows')
|
||||
|
||||
// Prevent multiple instances of the app from running at the same time. New instances
|
||||
// signal this instance and exit.
|
||||
var shouldQuit = app.makeSingleInstance(function (newArgv) {
|
||||
var shouldQuit = false
|
||||
var argv = sliceArgv(process.argv)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
shouldQuit = squirrelWin32.handleEvent(argv[0])
|
||||
argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1)
|
||||
}
|
||||
|
||||
if (!shouldQuit) {
|
||||
// Prevent multiple instances of app from running at same time. New instances signal
|
||||
// this instance and quit.
|
||||
shouldQuit = app.makeSingleInstance(onAppOpen)
|
||||
if (shouldQuit) {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldQuit) {
|
||||
init()
|
||||
}
|
||||
|
||||
function init () {
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
// Open handlers must be added as early as possible
|
||||
app.on('open-file', onOpen)
|
||||
app.on('open-url', onOpen)
|
||||
|
||||
ipc.init()
|
||||
|
||||
app.on('will-finish-launching', function () {
|
||||
autoUpdater.init()
|
||||
setupCrashReporter()
|
||||
})
|
||||
|
||||
app.on('ready', function () {
|
||||
menu.init()
|
||||
windows.createMainWindow()
|
||||
shortcuts.init()
|
||||
if (process.platform !== 'win32') handlers.init()
|
||||
})
|
||||
|
||||
app.on('ipcReady', function () {
|
||||
log('Command line args:', argv)
|
||||
argv.forEach(function (torrentId) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', function () {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
if (windows.main) {
|
||||
windows.main.show()
|
||||
} else {
|
||||
windows.createMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onOpen (e, torrentId) {
|
||||
e.preventDefault()
|
||||
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
// Magnet links opened from Chrome won't focus the app without a setTimeout. The
|
||||
// confirmation dialog Chrome shows causes Chrome to steal back the focus.
|
||||
// Electron issue: https://github.com/atom/electron/issues/4338
|
||||
setTimeout(function () {
|
||||
windows.focusMainWindow()
|
||||
}, 100)
|
||||
} else {
|
||||
argv.push(torrentId)
|
||||
}
|
||||
}
|
||||
|
||||
function onAppOpen (newArgv) {
|
||||
newArgv = sliceArgv(newArgv)
|
||||
|
||||
if (app.ipcReady) {
|
||||
log('Second app instance attempted to open but was prevented')
|
||||
log('Second app instance opened, but was prevented:', newArgv)
|
||||
windows.focusMainWindow()
|
||||
|
||||
newArgv.forEach(function (torrentId) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
})
|
||||
|
||||
if (windows.main.isMinimized()) {
|
||||
windows.main.restore()
|
||||
}
|
||||
windows.main.focus()
|
||||
} else {
|
||||
argv.push(...newArgv)
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldQuit) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
var argv = sliceArgv(process.argv)
|
||||
|
||||
app.on('open-file', onOpen)
|
||||
app.on('open-url', onOpen)
|
||||
app.on('will-finish-launching', function () {
|
||||
autoUpdater.init()
|
||||
setupCrashReporter()
|
||||
})
|
||||
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
app.on('ready', function () {
|
||||
menu.init()
|
||||
windows.createMainWindow()
|
||||
shortcuts.init()
|
||||
registerProtocolHandler()
|
||||
})
|
||||
|
||||
app.on('ipcReady', function () {
|
||||
log('IS_PRODUCTION:', config.IS_PRODUCTION)
|
||||
if (argv.length) {
|
||||
log('command line args:', process.argv)
|
||||
}
|
||||
argv.forEach(function (torrentId) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', function () {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
if (windows.main) {
|
||||
windows.main.show()
|
||||
} else {
|
||||
windows.createMainWindow(menu)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
ipc.init()
|
||||
|
||||
function onOpen (e, torrentId) {
|
||||
e.preventDefault()
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('dispatch', 'onOpen', torrentId)
|
||||
} else {
|
||||
argv.push(torrentId)
|
||||
}
|
||||
}
|
||||
|
||||
function sliceArgv (argv) {
|
||||
|
||||
134
main/squirrel-win32.js
Normal file
@@ -0,0 +1,134 @@
|
||||
module.exports = {
|
||||
handleEvent
|
||||
}
|
||||
|
||||
var cp = require('child_process')
|
||||
var electron = require('electron')
|
||||
var fs = require('fs')
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
var pathExists = require('path-exists')
|
||||
|
||||
var app = electron.app
|
||||
|
||||
var handlers = require('./handlers')
|
||||
|
||||
var exeName = path.basename(process.execPath)
|
||||
var updateDotExe = path.join(process.execPath, '..', '..', 'Update.exe')
|
||||
|
||||
function handleEvent (cmd) {
|
||||
if (cmd === '--squirrel-install') {
|
||||
// App was installed.
|
||||
|
||||
// Install protocol/file handlers, desktop/start menu shortcuts.
|
||||
handlers.init()
|
||||
|
||||
createShortcuts(function () {
|
||||
// Ensure user sees install splash screen so they realize that Setup.exe actually
|
||||
// installed an application and isn't the application itself.
|
||||
setTimeout(function () {
|
||||
app.quit()
|
||||
}, 5000)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-updated') {
|
||||
// App was updated. (Called on new version of app)
|
||||
updateShortcuts(function () {
|
||||
app.quit()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-uninstall') {
|
||||
// App was just uninstalled. Undo anything we did in the --squirrel-install and
|
||||
// --squirrel-updated handlers
|
||||
removeShortcuts(function () {
|
||||
app.quit()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-obsolete') {
|
||||
// App will be updated. (Called on outgoing version of app)
|
||||
app.quit()
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-firstrun') {
|
||||
// This is called on the app's first run. Do not quit, allow startup to continue.
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Spawn a command and invoke the callback when it completes with an error and the output
|
||||
// from standard out.
|
||||
function spawn (command, args, cb) {
|
||||
var stdout = ''
|
||||
|
||||
var child
|
||||
try {
|
||||
child = cp.spawn(command, args)
|
||||
} catch (err) {
|
||||
// Spawn can throw an error
|
||||
process.nextTick(function () {
|
||||
cb(error, stdout)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
child.stdout.on('data', function (data) {
|
||||
stdout += data
|
||||
})
|
||||
|
||||
var error = null
|
||||
child.on('error', function (processError) {
|
||||
error = processError
|
||||
})
|
||||
child.on('close', function (code, signal) {
|
||||
if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}')
|
||||
if (error) error.stdout = stdout
|
||||
cb(error, stdout)
|
||||
})
|
||||
}
|
||||
|
||||
// Spawn Squirrel's Update.exe with the given arguments and invoke the callback when the
|
||||
// command completes.
|
||||
function spawnUpdate (args, cb) {
|
||||
spawn(updateDotExe, args, cb)
|
||||
}
|
||||
|
||||
// Create desktop/start menu shortcuts using the Squirrel Update.exe command line API
|
||||
function createShortcuts (cb) {
|
||||
spawnUpdate(['--createShortcut', exeName], cb)
|
||||
}
|
||||
|
||||
// Update desktop/start menu shortcuts using the Squirrel Update.exe command line API
|
||||
function updateShortcuts (cb) {
|
||||
var homeDir = os.homedir()
|
||||
if (homeDir) {
|
||||
var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk')
|
||||
// Check if the desktop shortcut has been previously deleted and and keep it deleted
|
||||
// if it was
|
||||
pathExists(desktopShortcutPath).then(function (desktopShortcutExists) {
|
||||
createShortcuts(function () {
|
||||
if (desktopShortcutExists) {
|
||||
cb()
|
||||
} else {
|
||||
// Remove the unwanted desktop shortcut that was recreated
|
||||
fs.unlink(desktopShortcutPath, cb)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
createShortcuts(cb)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove desktop/start menu shortcuts using the Squirrel Update.exe command line API
|
||||
function removeShortcuts (cb) {
|
||||
spawnUpdate(['--removeShortcut', exeName], cb)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
var windows = module.exports = {
|
||||
main: null,
|
||||
createMainWindow: createMainWindow
|
||||
createMainWindow: createMainWindow,
|
||||
focusMainWindow: focusMainWindow
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
@@ -12,7 +13,6 @@ var menu = require('./menu')
|
||||
|
||||
function createMainWindow () {
|
||||
var win = windows.main = new electron.BrowserWindow({
|
||||
autoHideMenuBar: true, // Hide top menu bar unless Alt key is pressed (Windows, Linux)
|
||||
backgroundColor: '#282828',
|
||||
darkTheme: true, // Forces dark theme (GTK+3)
|
||||
icon: config.APP_ICON + '.png',
|
||||
@@ -52,3 +52,10 @@ function createMainWindow () {
|
||||
windows.main = null
|
||||
})
|
||||
}
|
||||
|
||||
function focusMainWindow () {
|
||||
if (windows.main.isMinimized()) {
|
||||
windows.main.restore()
|
||||
}
|
||||
windows.main.show() // shows and gives focus
|
||||
}
|
||||
|
||||
16
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webtorrent-app",
|
||||
"name": "WebTorrent",
|
||||
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Feross Aboukhadijeh",
|
||||
"email": "feross@feross.org",
|
||||
@@ -22,23 +22,29 @@
|
||||
"hyperx": "^2.0.2",
|
||||
"main-loop": "^3.2.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"musicmetadata": "^2.0.2",
|
||||
"network-address": "^1.1.0",
|
||||
"path-exists": "^2.1.0",
|
||||
"prettier-bytes": "^1.0.1",
|
||||
"upload-element": "^1.0.1",
|
||||
"virtual-dom": "^2.1.1",
|
||||
"webtorrent": "^0.86.0",
|
||||
"webtorrent": "^0.87.1",
|
||||
"winreg": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"appdmg": "^0.3.6",
|
||||
"electron-osx-sign": "^0.3.0",
|
||||
"electron-packager": "^5.0.0",
|
||||
"electron-prebuilt": "0.37.2",
|
||||
"electron-winstaller": "^2.0.5",
|
||||
"gh-release": "^2.0.2",
|
||||
"path-exists": "^2.1.0",
|
||||
"plist": "^1.2.0",
|
||||
"rimraf": "^2.5.2",
|
||||
"standard": "^6.0.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"appdmg": "^0.3.6"
|
||||
},
|
||||
"homepage": "https://webtorrent.io",
|
||||
"keywords": [
|
||||
"electron",
|
||||
@@ -54,7 +60,7 @@
|
||||
"scripts": {
|
||||
"clean": "node ./bin/clean.js",
|
||||
"debug": "DEBUG=* electron .",
|
||||
"package": "npm prune && npm dedupe && node ./bin/package.js",
|
||||
"package": "npm install && npm prune && npm dedupe && node ./bin/package.js",
|
||||
"size": "npm run package -- --darwin && du -ch dist/WebTorrent-darwin-x64 | grep total",
|
||||
"start": "electron .",
|
||||
"test": "standard",
|
||||
|
||||
@@ -181,10 +181,10 @@ i:not(.disabled):hover {
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease-out;
|
||||
font-size: 14px;
|
||||
line-height: 1.5em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app:not(.is-focused) .header {
|
||||
@@ -456,8 +456,7 @@ input {
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
.torrent.timeout .play,
|
||||
.torrent.unplayable .play {
|
||||
.torrent.timeout .play {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -732,13 +731,40 @@ body.drag .torrent-placeholder span {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/*
|
||||
* AUDIO DETAILS
|
||||
*/
|
||||
|
||||
.audio-metadata {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
align-self: center;
|
||||
margin: 0 auto;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.audio-metadata .audio-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.audio-metadata label {
|
||||
display:inline-block;
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
/*
|
||||
* ERRORS
|
||||
*/
|
||||
|
||||
.error-popover {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
top: 36px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -8,6 +8,7 @@ var EventEmitter = require('events')
|
||||
var fs = require('fs')
|
||||
var mainLoop = require('main-loop')
|
||||
var mkdirp = require('mkdirp')
|
||||
var musicmetadata = require('musicmetadata')
|
||||
var networkAddress = require('network-address')
|
||||
var path = require('path')
|
||||
var remote = require('remote')
|
||||
@@ -130,7 +131,7 @@ function init () {
|
||||
|
||||
// Done! Ideally we want to get here <100ms after the user clicks the app
|
||||
document.querySelector('.loading').remove() /* TODO: no spinner once fast enough */
|
||||
playInterfaceSound(config.SOUND_STARTUP)
|
||||
playInterfaceSound('STARTUP')
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ function updateElectron () {
|
||||
|
||||
// Events from the UI never modify state directly. Instead they call dispatch()
|
||||
function dispatch (action, ...args) {
|
||||
if (['videoMouseMoved', 'playbackJump'].indexOf(action) === -1) {
|
||||
if (['mediaMouseMoved', 'playbackJump'].indexOf(action) === -1) {
|
||||
console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */
|
||||
}
|
||||
if (action === 'onOpen') {
|
||||
@@ -235,20 +236,20 @@ function dispatch (action, ...args) {
|
||||
if (action === 'playbackJump') {
|
||||
jumpToTime(args[0] /* seconds */)
|
||||
}
|
||||
if (action === 'videoPlaying') {
|
||||
state.video.isPaused = false
|
||||
if (action === 'mediaPlaying') {
|
||||
state.playing.isPaused = false
|
||||
ipcRenderer.send('blockPowerSave')
|
||||
}
|
||||
if (action === 'videoPaused') {
|
||||
state.video.isPaused = true
|
||||
if (action === 'mediaPaused') {
|
||||
state.playing.isPaused = true
|
||||
ipcRenderer.send('unblockPowerSave')
|
||||
}
|
||||
if (action === 'toggleFullScreen') {
|
||||
ipcRenderer.send('toggleFullScreen', args[0])
|
||||
update()
|
||||
}
|
||||
if (action === 'videoMouseMoved') {
|
||||
state.video.mouseStationarySince = new Date().getTime()
|
||||
if (action === 'mediaMouseMoved') {
|
||||
state.playing.mouseStationarySince = new Date().getTime()
|
||||
update()
|
||||
}
|
||||
if (action === 'exitModal') {
|
||||
@@ -259,14 +260,14 @@ function dispatch (action, ...args) {
|
||||
|
||||
// Plays or pauses the video. If isPaused is undefined, acts as a toggle
|
||||
function playPause (isPaused) {
|
||||
if (isPaused === state.video.isPaused) {
|
||||
if (isPaused === state.playing.isPaused) {
|
||||
return // Nothing to do
|
||||
}
|
||||
// Either isPaused is undefined, or it's the opposite of the current state. Toggle.
|
||||
if (Cast.isCasting()) {
|
||||
Cast.playPause()
|
||||
}
|
||||
state.video.isPaused = !state.video.isPaused
|
||||
state.playing.isPaused = !state.playing.isPaused
|
||||
update()
|
||||
}
|
||||
|
||||
@@ -274,7 +275,7 @@ function jumpToTime (time) {
|
||||
if (Cast.isCasting()) {
|
||||
Cast.seek(time)
|
||||
} else {
|
||||
state.video.jumpToTime = time
|
||||
state.playing.jumpToTime = time
|
||||
update()
|
||||
}
|
||||
}
|
||||
@@ -422,7 +423,7 @@ function addTorrentToList (torrent) {
|
||||
infoHash: torrent.infoHash
|
||||
})
|
||||
saveState()
|
||||
playInterfaceSound(config.SOUND_ADD)
|
||||
playInterfaceSound('ADD')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +512,7 @@ function updateTorrentProgress () {
|
||||
var changed = false
|
||||
state.client.torrents.forEach(function (torrent) {
|
||||
var torrentSummary = getTorrentSummary(torrent.infoHash)
|
||||
if (!torrentSummary) return
|
||||
if (!torrentSummary || !torrent.ready) return
|
||||
torrent.files.forEach(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
@@ -601,21 +602,25 @@ function startServer (torrentSummary, index, cb) {
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
// automatically choose which file in the torrent to play, if necessary
|
||||
if (!index) {
|
||||
// filter out file formats that the <video> tag definitely can't play
|
||||
var files = torrent.files.filter(TorrentPlayer.isPlayable)
|
||||
if (files.length === 0) return cb(new errors.UnplayableError())
|
||||
// use largest file
|
||||
var largestFile = files.reduce(function (a, b) {
|
||||
return a.length > b.length ? a : b
|
||||
})
|
||||
index = torrent.files.indexOf(largestFile)
|
||||
}
|
||||
if (index === undefined) index = pickFileToPlay(torrent.files)
|
||||
if (index === undefined) return cb(new errors.UnplayableError())
|
||||
var file = torrent.files[index]
|
||||
|
||||
// update state
|
||||
state.playing.infoHash = torrent.infoHash
|
||||
state.playing.fileIndex = index
|
||||
state.playing.type = TorrentPlayer.isVideo(file) ? 'video' : 'audio'
|
||||
state.playing.audioInfo = null
|
||||
|
||||
// if it's audio, parse out the metadata (artist, title, etc)
|
||||
musicmetadata(file.createReadStream(), function (err, info) {
|
||||
if (err) return
|
||||
console.log('Got audio metadata for %s: %v', file.name, info)
|
||||
state.playing.audioInfo = info
|
||||
update()
|
||||
})
|
||||
|
||||
// either way, start a streaming torrent-to-http server
|
||||
var server = torrent.createServer()
|
||||
server.listen(0, function () {
|
||||
var port = server.address().port
|
||||
@@ -629,6 +634,28 @@ function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
})
|
||||
}
|
||||
|
||||
// Picks the default file to play from a list of torrent or torrentSummary files
|
||||
// Returns an index or undefined, if no files are playable
|
||||
function pickFileToPlay (files) {
|
||||
// first, try to find the biggest video file
|
||||
var videoFiles = files.filter(TorrentPlayer.isVideo)
|
||||
if (videoFiles.length > 0) {
|
||||
var largestVideoFile = videoFiles.reduce(function (a, b) {
|
||||
return a.length > b.length ? a : b
|
||||
})
|
||||
return files.indexOf(largestVideoFile)
|
||||
}
|
||||
|
||||
// if there are no videos, play the first audio file
|
||||
var audioFiles = files.filter(TorrentPlayer.isAudio)
|
||||
if (audioFiles.length > 0) {
|
||||
return files.indexOf(audioFiles[0])
|
||||
}
|
||||
|
||||
// no video or audio means nothing is playable
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!state.server) return
|
||||
state.server.server.destroy()
|
||||
@@ -640,13 +667,13 @@ function stopServer () {
|
||||
// Opens the video player
|
||||
function openPlayer (torrentSummary, index, cb) {
|
||||
var torrent = state.client.get(torrentSummary.infoHash)
|
||||
if (!torrent || !torrent.done) playInterfaceSound(config.SOUND_PLAY)
|
||||
if (!torrent || !torrent.done) playInterfaceSound('PLAY')
|
||||
torrentSummary.playStatus = 'requested'
|
||||
update()
|
||||
|
||||
var timeout = setTimeout(function () {
|
||||
torrentSummary.playStatus = 'timeout' /* no seeders available? */
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
playInterfaceSound('ERROR')
|
||||
update()
|
||||
}, 10000) /* give it a few seconds */
|
||||
|
||||
@@ -654,7 +681,7 @@ function openPlayer (torrentSummary, index, cb) {
|
||||
clearTimeout(timeout)
|
||||
if (err) {
|
||||
torrentSummary.playStatus = 'unplayable'
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
playInterfaceSound('ERROR')
|
||||
update()
|
||||
return onError(err)
|
||||
}
|
||||
@@ -714,11 +741,11 @@ function toggleTorrent (torrentSummary) {
|
||||
if (torrentSummary.status === 'paused') {
|
||||
torrentSummary.status = 'new'
|
||||
startTorrentingSummary(torrentSummary)
|
||||
playInterfaceSound(config.SOUND_ENABLE)
|
||||
playInterfaceSound('ENABLE')
|
||||
} else {
|
||||
torrentSummary.status = 'paused'
|
||||
stopTorrenting(torrentSummary.infoHash)
|
||||
playInterfaceSound(config.SOUND_DISABLE)
|
||||
playInterfaceSound('DISABLE')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,7 +758,7 @@ function deleteTorrent (torrentSummary) {
|
||||
if (index > -1) state.saved.torrents.splice(index, 1)
|
||||
saveState()
|
||||
state.location.clearForward() // prevent user from going forward to a deleted torrent
|
||||
playInterfaceSound(config.SOUND_DELETE)
|
||||
playInterfaceSound('DELETE')
|
||||
}
|
||||
|
||||
function toggleSelectTorrent (infoHash) {
|
||||
@@ -785,7 +812,7 @@ function restoreBounds () {
|
||||
|
||||
function onError (err) {
|
||||
console.error(err.stack || err)
|
||||
playInterfaceSound(config.SOUND_ERROR)
|
||||
playInterfaceSound('ERROR')
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: err.message || err
|
||||
@@ -809,12 +836,15 @@ function showDoneNotification (torrent) {
|
||||
window.focus()
|
||||
}
|
||||
|
||||
playInterfaceSound(config.SOUND_DONE)
|
||||
playInterfaceSound('DONE')
|
||||
}
|
||||
|
||||
function playInterfaceSound (url) {
|
||||
function playInterfaceSound (name) {
|
||||
var sound = config[`SOUND_${name}`]
|
||||
if (!sound) throw new Error('Invalid sound name')
|
||||
|
||||
var audio = new window.Audio()
|
||||
audio.volume = 0.3
|
||||
audio.src = url
|
||||
audio.volume = sound.volume
|
||||
audio.src = sound.url
|
||||
audio.play()
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@ function pollCastStatus (state) {
|
||||
if (state.playing.location === 'chromecast') {
|
||||
state.devices.chromecast.status(function (err, status) {
|
||||
if (err) return console.log('Error getting %s status: %o', state.playing.location, err)
|
||||
state.video.isPaused = status.playerState === 'PAUSED'
|
||||
state.video.currentTime = status.currentTime
|
||||
state.playing.isPaused = status.playerState === 'PAUSED'
|
||||
state.playing.currentTime = status.currentTime
|
||||
update()
|
||||
})
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
state.devices.airplay.status(function (status) {
|
||||
state.video.isPaused = status.rate === 0
|
||||
state.video.currentTime = status.position
|
||||
state.playing.isPaused = status.rate === 0
|
||||
state.playing.currentTime = status.position
|
||||
update()
|
||||
})
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function stopCasting () {
|
||||
|
||||
function stoppedCasting () {
|
||||
state.playing.location = 'local'
|
||||
state.video.jumpToTime = state.video.currentTime
|
||||
state.playing.jumpToTime = state.playing.currentTime
|
||||
update()
|
||||
}
|
||||
|
||||
@@ -137,11 +137,11 @@ function playPause () {
|
||||
var device
|
||||
if (state.playing.location === 'chromecast') {
|
||||
device = state.devices.chromecast
|
||||
if (!state.video.isPaused) device.pause(castCallback)
|
||||
if (!state.playing.isPaused) device.pause(castCallback)
|
||||
else device.play(null, null, castCallback)
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
device = state.devices.airplay
|
||||
if (!state.video.isPaused) device.rate(0, castCallback)
|
||||
if (!state.playing.isPaused) device.rate(0, castCallback)
|
||||
else device.rate(1, castCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
module.exports = {
|
||||
isPlayable: isPlayable
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
@@ -8,6 +10,15 @@ var path = require('path')
|
||||
* Determines whether a file in a torrent is audio/video we can play
|
||||
*/
|
||||
function isPlayable (file) {
|
||||
var extname = path.extname(file.name)
|
||||
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(extname) !== -1
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
function isVideo (file) {
|
||||
var ext = path.extname(file.name)
|
||||
return ['.mp4', '.m4v', '.webm', '.mov', '.mkv'].indexOf(ext) !== -1
|
||||
}
|
||||
|
||||
function isAudio (file) {
|
||||
var ext = path.extname(file.name)
|
||||
return ['.mp3', '.aac', '.ogg', '.wav'].indexOf(ext) !== -1
|
||||
}
|
||||
|
||||
@@ -20,21 +20,20 @@ module.exports = {
|
||||
title: config.APP_NAME /* current window title */
|
||||
},
|
||||
selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */
|
||||
playing: { /* the torrent and file we're currently streaming */
|
||||
playing: { /* the media (audio or video) that we're currently playing */
|
||||
infoHash: null, /* the info hash of the torrent we're playing */
|
||||
fileIndex: null, /* the zero-based index within the torrent */
|
||||
location: 'local' /* 'local', 'chromecast', 'airplay' */
|
||||
},
|
||||
devices: { /* playback devices like Chromecast and AppleTV */
|
||||
airplay: null, /* airplay client. finds and manages AppleTVs */
|
||||
chromecast: null /* chromecast client. finds and manages Chromecasts */
|
||||
},
|
||||
video: { /* state of the video player screen */
|
||||
location: 'local', /* 'local', 'chromecast', 'airplay' */
|
||||
type: null, /* 'audio' or 'video' */
|
||||
currentTime: 0, /* seconds */
|
||||
duration: 1, /* seconds */
|
||||
isPaused: true,
|
||||
mouseStationarySince: 0 /* Unix time in ms */
|
||||
},
|
||||
devices: { /* playback devices like Chromecast and AppleTV */
|
||||
airplay: null, /* airplay client. finds and manages AppleTVs */
|
||||
chromecast: null /* chromecast client. finds and manages Chromecasts */
|
||||
},
|
||||
dock: {
|
||||
badge: 0,
|
||||
progress: 0
|
||||
|
||||
@@ -18,9 +18,9 @@ function App (state, dispatch) {
|
||||
// * The video is paused
|
||||
// * The video is playing remotely on Chromecast or Airplay
|
||||
var hideControls = state.location.current().url === 'player' &&
|
||||
state.video.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.video.mouseStationarySince > 2000 &&
|
||||
!state.video.isPaused &&
|
||||
state.playing.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
|
||||
!state.playing.isPaused &&
|
||||
state.playing.location === 'local'
|
||||
|
||||
// Hide the header on Windows/Linux when in the player
|
||||
|
||||
@@ -4,6 +4,7 @@ var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
|
||||
|
||||
// Shows a streaming video player. Standard features + Chromecast + Airplay
|
||||
function Player (state, dispatch) {
|
||||
// Show the video as large as will fit in the window, play immediately
|
||||
@@ -12,50 +13,69 @@ function Player (state, dispatch) {
|
||||
return hx`
|
||||
<div
|
||||
class='player'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
${showVideo ? renderVideo(state, dispatch) : renderCastScreen(state, dispatch)}
|
||||
onmousemove=${() => dispatch('mediaMouseMoved')}>
|
||||
${showVideo ? renderMedia(state, dispatch) : renderCastScreen(state, dispatch)}
|
||||
${renderPlayerControls(state, dispatch)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderVideo (state, dispatch) {
|
||||
function renderMedia (state, dispatch) {
|
||||
if (!state.server) return
|
||||
|
||||
// Unfortunately, play/pause can't be done just by modifying HTML.
|
||||
// Instead, grab the DOM node and play/pause it if necessary
|
||||
var videoElement = document.querySelector('video')
|
||||
if (videoElement !== null) {
|
||||
if (state.video.isPaused && !videoElement.paused) {
|
||||
videoElement.pause()
|
||||
} else if (!state.video.isPaused && videoElement.paused) {
|
||||
videoElement.play()
|
||||
var mediaType = state.playing.type /* 'audio' or 'video' */
|
||||
var mediaElement = document.querySelector(mediaType) /* get the <video> or <audio> tag */
|
||||
if (mediaElement !== null) {
|
||||
if (state.playing.isPaused && !mediaElement.paused) {
|
||||
mediaElement.pause()
|
||||
} else if (!state.playing.isPaused && mediaElement.paused) {
|
||||
mediaElement.play()
|
||||
}
|
||||
// When the user clicks or drags on the progress bar, jump to that position
|
||||
if (state.video.jumpToTime) {
|
||||
videoElement.currentTime = state.video.jumpToTime
|
||||
state.video.jumpToTime = null
|
||||
if (state.playing.jumpToTime) {
|
||||
mediaElement.currentTime = state.playing.jumpToTime
|
||||
state.playing.jumpToTime = null
|
||||
}
|
||||
state.video.currentTime = videoElement.currentTime
|
||||
state.video.duration = videoElement.duration
|
||||
state.playing.currentTime = mediaElement.currentTime
|
||||
state.playing.duration = mediaElement.duration
|
||||
}
|
||||
|
||||
// Create the <audio> or <video> tag
|
||||
var mediaTag = hx`
|
||||
<div
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${() => dispatch('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onplay=${() => dispatch('mediaPlaying')}
|
||||
onpause=${() => dispatch('mediaPaused')}
|
||||
autoplay>
|
||||
</div>
|
||||
`
|
||||
mediaTag.tagName = mediaType
|
||||
|
||||
// Show the media.
|
||||
// Video fills the window, centered with black bars if necessary
|
||||
// Audio gets a static poster image and a summary of the file metadata.
|
||||
var isAudio = mediaType === 'audio'
|
||||
var style = {
|
||||
backgroundImage: isAudio ? cssBackgroundImagePoster(state) : ''
|
||||
}
|
||||
return hx`
|
||||
<div
|
||||
class='letterbox'
|
||||
onmousemove=${() => dispatch('videoMouseMoved')}>
|
||||
<video
|
||||
src='${state.server.localURL}'
|
||||
ondblclick=${() => dispatch('toggleFullScreen')}
|
||||
onloadedmetadata=${onLoadedMetadata}
|
||||
onended=${onEnded}
|
||||
onplay=${() => dispatch('videoPlaying')}
|
||||
onpause=${() => dispatch('videoPaused')}
|
||||
autoplay>
|
||||
</video>
|
||||
style=${style}
|
||||
onmousemove=${() => dispatch('mediaMouseMoved')}>
|
||||
${mediaTag}
|
||||
${renderAudioMetadata(state)}
|
||||
</div>
|
||||
`
|
||||
|
||||
// As soon as the video loads enough to know the video dimensions, resize the window
|
||||
function onLoadedMetadata (e) {
|
||||
if (mediaType !== 'video') return
|
||||
var video = e.target
|
||||
var dimensions = {
|
||||
width: video.videoWidth,
|
||||
@@ -66,10 +86,38 @@ function renderVideo (state, dispatch) {
|
||||
|
||||
// When the video completes, pause the video instead of looping
|
||||
function onEnded (e) {
|
||||
state.video.isPaused = true
|
||||
state.playing.isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
function renderAudioMetadata (state) {
|
||||
if (!state.playing.audioInfo) return
|
||||
var info = state.playing.audioInfo
|
||||
|
||||
// Get audio track info
|
||||
var title = info.title
|
||||
if (!title) {
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
title = torrentSummary.files[state.playing.fileIndex].name
|
||||
}
|
||||
var artist = info.artist && info.artist[0]
|
||||
var album = info.album
|
||||
if (album && info.year && !album.includes(info.year)) {
|
||||
album += ' (' + info.year + ')'
|
||||
}
|
||||
var track
|
||||
if (info.track && info.track.no && info.track.of) {
|
||||
track = info.track.no + ' of ' + info.track.of
|
||||
}
|
||||
|
||||
// Show a small info box in the middle of the screen
|
||||
var elems = [hx`<div class='audio-title'><label></label>${title}</div>`]
|
||||
if (artist) elems.push(hx`<div class='audio-artist'><label>Artist</label>${artist}</div>`)
|
||||
if (album) elems.push(hx`<div class='audio-album'><label>Album</label>${album}</div>`)
|
||||
if (track) elems.push(hx`<div class='audio-track'><label>Track</label>${track}</div>`)
|
||||
return hx`<div class='audio-metadata'>${elems}</div>`
|
||||
}
|
||||
|
||||
function renderCastScreen (state, dispatch) {
|
||||
var isChromecast = state.playing.location.startsWith('chromecast')
|
||||
var isAirplay = state.playing.location.startsWith('airplay')
|
||||
@@ -77,12 +125,8 @@ function renderCastScreen (state, dispatch) {
|
||||
if (!isChromecast && !isAirplay) throw new Error('Unimplemented cast type')
|
||||
|
||||
// Show a nice title image, if possible
|
||||
var style = {}
|
||||
var infoHash = state.playing.infoHash
|
||||
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
if (torrentSummary && torrentSummary.posterURL) {
|
||||
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
|
||||
style.backgroundImage = `radial-gradient(circle at center, rgba(0,0,0,0.4) 0%,rgba(0,0,0,1) 100%), url(${cleanURL})`
|
||||
var style = {
|
||||
backgroundImage: cssBackgroundImagePoster(state)
|
||||
}
|
||||
|
||||
// Show whether we're connected to Chromecast / Airplay
|
||||
@@ -98,8 +142,23 @@ function renderCastScreen (state, dispatch) {
|
||||
`
|
||||
}
|
||||
|
||||
// Returns the CSS background-image string for a poster image + dark vignette
|
||||
function cssBackgroundImagePoster (state) {
|
||||
var torrentSummary = getPlayingTorrentSummary(state)
|
||||
if (!torrentSummary || !torrentSummary.posterURL) return ''
|
||||
var cleanURL = torrentSummary.posterURL.replace(/\\/g, '/')
|
||||
return 'radial-gradient(circle at center, ' +
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' +
|
||||
`, url(${cleanURL})`
|
||||
}
|
||||
|
||||
function getPlayingTorrentSummary (state) {
|
||||
var infoHash = state.playing.infoHash
|
||||
return state.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
function renderPlayerControls (state, dispatch) {
|
||||
var positionPercent = 100 * state.video.currentTime / state.video.duration
|
||||
var positionPercent = 100 * state.playing.currentTime / state.playing.duration
|
||||
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' }
|
||||
|
||||
var elements = [
|
||||
@@ -174,7 +233,7 @@ function renderPlayerControls (state, dispatch) {
|
||||
// Finally, the big button in the center plays or pauses the video
|
||||
elements.push(hx`
|
||||
<i class='icon play-pause' onclick=${() => dispatch('playPause')}>
|
||||
${state.video.isPaused ? 'play_arrow' : 'pause'}
|
||||
${state.playing.isPaused ? 'play_arrow' : 'pause'}
|
||||
</i>
|
||||
`)
|
||||
|
||||
@@ -182,10 +241,10 @@ function renderPlayerControls (state, dispatch) {
|
||||
|
||||
// Handles a click or drag to scrub (jump to another position in the video)
|
||||
function handleScrub (e) {
|
||||
dispatch('videoMouseMoved')
|
||||
dispatch('mediaMouseMoved')
|
||||
var windowWidth = document.querySelector('body').clientWidth
|
||||
var fraction = e.clientX / windowWidth
|
||||
var position = fraction * state.video.duration /* seconds */
|
||||
var position = fraction * state.playing.duration /* seconds */
|
||||
dispatch('playbackJump', position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,10 @@ function TorrentList (state, dispatch) {
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
function renderTorrentButtons (torrentSummary) {
|
||||
var playIcon, playTooltip
|
||||
var playIcon, playTooltip, playClass
|
||||
if (torrentSummary.playStatus === 'unplayable') {
|
||||
playIcon = 'warning'
|
||||
playIcon = 'play_arrow'
|
||||
playClass = 'disabled'
|
||||
playTooltip = 'Sorry, WebTorrent can\'t play any of the files in this torrent. ' +
|
||||
'View details and click on individual files to open them in another program.'
|
||||
} else if (torrentSummary.playStatus === 'timeout') {
|
||||
@@ -131,13 +132,14 @@ function TorrentList (state, dispatch) {
|
||||
return hx`
|
||||
<div class='buttons'>
|
||||
<i.btn.icon.play
|
||||
title='${playTooltip}'
|
||||
title=${playTooltip}
|
||||
class=${playClass}
|
||||
onclick=${(e) => handleButton('play', e)}>
|
||||
${playIcon}
|
||||
</i>
|
||||
<i.btn.icon.download
|
||||
class='${torrentSummary.status}'
|
||||
title='${downloadTooltip}'
|
||||
class=${torrentSummary.status}
|
||||
title=${downloadTooltip}
|
||||
onclick=${(e) => handleButton('toggleTorrent', e)}>
|
||||
${downloadIcon}
|
||||
</i>
|
||||
@@ -153,6 +155,7 @@ function TorrentList (state, dispatch) {
|
||||
function handleButton (action, e) {
|
||||
// Prevent propagation so that we don't select/unselect the torrent
|
||||
e.stopPropagation()
|
||||
if (e.target.classList.contains('disabled')) return
|
||||
dispatch(action, torrentSummary)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 303 KiB |
BIN
static/loading.gif
Normal file
|
After Width: | Height: | Size: 20 KiB |