Compare commits

...

328 Commits

Author SHA1 Message Date
Alberto Miranda
e2c3c182d0 Plugins divided into main and renderer processes. 2017-04-23 13:46:52 -03:00
Alberto Miranda
687038560c Passing state and params to plugins.
Dispatcher now sends params to plugins; notifying about plugin load
errors; allowing local plugin paths not to end with a slash.
2017-04-19 09:00:11 -03:00
Alberto Miranda
09d00c6383 Passing dispatcher to plugins.
This will enable plugins to dispatch events and interact with the
renderer process; removed unused extension keys.
2017-04-16 16:31:39 -03:00
Alberto Miranda
957c5b64e9 Merge branch 'master' into plugins 2017-04-16 13:29:51 -03:00
Feross Aboukhadijeh
a82498ba16 gitter 2017-04-14 14:40:18 -07:00
Feross Aboukhadijeh
3b05b52e57 feross -> webtorrent 2017-04-14 13:48:39 -07:00
Feross Aboukhadijeh
cdfb907c75 standard 2017-03-20 19:41:44 -07:00
Alberto Miranda
c2abb50e9e Merge pull request #840 from feross/playback-priority
Playback priority
2017-03-15 14:39:26 -03:00
Alberto Miranda
99f4fc96bf Fixed resuming when downloading finishes. 2017-03-14 19:52:29 -03:00
Alberto Miranda
043f81996e fixed styling issues; returning early in pauseActiveTorrents. 2017-03-14 19:50:49 -03:00
Alberto Miranda
213f2f124d Merge branch 'playback-priority' of https://github.com/feross/webtorrent-desktop into playback-priority 2017-03-14 19:44:45 -03:00
Alberto Miranda
96e7e83e20 Merge branch 'master' into playback-priority
# Conflicts:
#	src/renderer/controllers/playback-controller.js
#	src/renderer/controllers/torrent-list-controller.js
#	src/renderer/main.js
2017-03-14 19:43:57 -03:00
Feross Aboukhadijeh
e5fccd93a8 Tweak pinch-to-zoom timing
Toggle fullscreen immediately, then prevent future toggles for 1s.
2017-03-08 15:00:42 -08:00
Feross Aboukhadijeh
78e46f5576 Merge branch 'ebraminio-master' 2017-03-08 14:56:38 -08:00
Ebrahim Byagowi
69ce07fbf7 Toggle fullscreen on pinch to zoom 2017-03-08 23:13:05 +03:30
Feross Aboukhadijeh
827131e136 standard 2017-03-02 00:33:36 -08:00
Feross Aboukhadijeh
c764bf4884 fixes for standard v10 2017-03-01 23:08:29 -08:00
Feross Aboukhadijeh
972203d675 material-ui@0.17
Tested and everything seems to work alright with the new version.
2017-02-20 03:09:56 -08:00
Feross Aboukhadijeh
2c382e14b5 electron@1.6.0
This version of Electron is 3 versions of Chromium newer, so there are
lots of performance improvements.

There is one known issue with this update:
https://github.com/electron/electron/issues/8694

But I think we should merge this anyway so that collaborators can try
out the new Chromium engine before we do a release with it. We might
find other bugs.
2017-02-15 15:31:35 -08:00
Feross Aboukhadijeh
c298950d34 Add 'Start Speaking' and 'Stop Speaking' menu item (Mac)
Probably improves the accessibility situation a little bit: #439
2017-02-15 13:31:28 -08:00
Feross Aboukhadijeh
00f2e5ccd6 Treat .FLAC as playable audio
Fixes: https://github.com/feross/webtorrent-desktop/issues/1124

Note FLAC is not actually playable in Electron, by default. But this
will at least offer to open it in VLC or the user's preferred player.
2017-02-15 13:31:06 -08:00
Feross Aboukhadijeh
8d86cffabd standard 2017-02-08 16:07:25 -08:00
Alberto Miranda
568fede67e fixed periodic plugins update 2017-02-06 08:06:02 -03:00
Alberto Miranda
944eb8b8b0 styling fixes; tests passing; added missing ms dependency. 2017-02-06 00:54:16 -03:00
Alberto Miranda
c3cdda354e added node-notifier; showing plugin notifications. 2017-02-06 00:38:27 -03:00
Alberto Miranda
25ed12ba3c removed extra logging; cleanup 2017-02-06 00:00:26 -03:00
Alberto Miranda
830312842b hot reloading config; hot loading plugins when config changes. 2017-02-05 13:15:20 -03:00
Feross Aboukhadijeh
95d6ec5fdd move release instructions to contributing.md 2017-02-04 18:00:50 -08:00
DC
9fbee6cfeb Replace release scripts with instructions
The release scripts don't really work, and there are a lot of manual steps that weren't documented. They are now.
2017-02-05 02:55:13 +01:00
Feross Aboukhadijeh
9892f88530 Fix 'About WebTorrent' menu location on Windows 2017-02-03 03:27:35 -08:00
Feross Aboukhadijeh
10ad990f97 update AUTHORS 2017-02-03 02:17:04 -08:00
Feross Aboukhadijeh
0ee7e80fd2 0.18.0 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
7d1520f858 disable background throttling in all windows 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
77ba258fbf move Transfers menu 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
06366529cc updated copyright year 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
d1c263f1b0 changelog for 0.18.0 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
d7a031a457 test: update test for new default trackers 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
b7201f424d test: update screenshot for new default trackers 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
89f2785244 remove unneeded package from depcheck 2017-02-03 02:08:59 -08:00
Feross Aboukhadijeh
cffe416f50 package: fix deprecation warning 2017-02-03 02:01:54 -08:00
Feross Aboukhadijeh
d0eea34e9b lock down dev dependencies
Let's be very explicit about updating these. They are very critical to
the build process, so we should always read the changelogs / commit log
before upgrading.
2017-02-03 02:01:54 -08:00
Feross Aboukhadijeh
4e82718788 electron-osx-sign@0.4 2017-02-03 02:01:54 -08:00
Feross Aboukhadijeh
293ca60e72 electron@1.4.15 2017-02-03 02:01:54 -08:00
Feross Aboukhadijeh
af4a6a5960 buble@0.15 2017-02-03 02:01:54 -08:00
Nuno Campos
e01c0b6f43 Add textarea to list of html tags that skip document-wide onPaste 2017-02-02 15:17:10 -08:00
Alberto Miranda
086d8bf00a fixed initialization on first run 2017-02-01 14:35:28 -03:00
Alberto Miranda
50c100130a removed unused packages; hashing installed plugins; saving plugins config; detecting newly installed plugins. 2017-02-01 00:34:28 -03:00
Alberto Miranda
edcea2661a working on plugins features: lots of todos, but current version loads remote and local plugins. 2017-01-30 00:08:22 -03:00
Feross Aboukhadijeh
efc984f1dd Include policy about copyrighted content in issues in issue template 2017-01-20 19:07:42 -08:00
Feross Aboukhadijeh
a046db40d2 Update react-tap-event-plugin 2.0.1
Fixes https://github.com/feross/webtorrent-desktop/issues/1086
2016-12-07 21:01:12 -08:00
Feross Aboukhadijeh
9068909b4d fixes for standard 2016-11-22 19:43:51 -03:00
Mathias Rasmussen
4bb2056bc9 Auto-focus modal 'OK' button to allow keypress 2016-10-24 14:43:19 -05:00
Karan Thakkar
ed0f05abc4 Pause All will now pause seeding torrents too 2016-10-24 13:33:38 -05:00
Karan Thakkar
0c44e10ca7 Move sound.play outisde the for loop 2016-10-24 13:33:38 -05:00
Karan Thakkar
d8c9014471 Add a new Transfers menu to allow pause all and resume all torrents 2016-10-24 13:33:38 -05:00
Feross Aboukhadijeh
7cf1d96a80 Fixes for PR #1050 2016-10-18 12:49:09 +01:00
Adam Gotlib
ffd9fbda57 Disallow saving torrent file until it is generated
Fixes #1031.
2016-10-18 12:46:33 +01:00
Adam Gotlib
d8904aaf6e Log successful attempts to open an external player 2016-10-13 23:31:34 -05:00
Adam Gotlib
de2db211cc Log play attempt separately for each file 2016-10-13 23:31:34 -05:00
Adam Gotlib
e8ab172d1b Remove unused 'timeout' field from telemetry 2016-10-13 23:31:34 -05:00
Alexey Romanov
32f96c03dd #1039: Formatting fixed.
Removed comma and added space after download speed.
2016-10-13 21:24:17 -07:00
Feross Aboukhadijeh
5158606740 Remove lodash.merge require() hack
See:
https://github.com/callemall/material-ui/pull/4380#issuecomment-25089455
2

My PR to material-ui to replace the full `lodash` with just the needed
functions was merged. So we can remove this hack.
2016-10-12 17:53:52 -05:00
Feross Aboukhadijeh
0de80165ed material-ui@0.16 2016-10-12 17:53:52 -05:00
Feross Aboukhadijeh
dcaa99d2bf es6-error@4 2016-10-12 17:53:52 -05:00
Feross Aboukhadijeh
1a1a4cd5d0 electron 1.4.3
Changelog: https://github.com/electron/electron/releases/tag/v1.4.3
2016-10-12 17:53:52 -05:00
Feross Aboukhadijeh
4cbad1fccd 0.17.2 2016-10-10 22:44:08 -07:00
Feross Aboukhadijeh
f818564dd1 package.json: Add {"private": true}
To prevent accidental publishing to npm.
2016-10-10 22:41:17 -07:00
Feross Aboukhadijeh
8be690a26e v0.17.2 changelog 2016-10-10 22:37:24 -07:00
Feross Aboukhadijeh
8081d42477 Attempt to fix "TypeError: Cannot read property 'startPiece' of undefined"
It wasn't clear what was causing this error, and I couldn't reproduce
it.

This PR attempts to resolve the issue by just guarding against the
exception.
2016-10-10 22:31:33 -07:00
Feross Aboukhadijeh
b0b6069fe2 remove 'pug' from nodemon command (we don't use pug) 2016-10-06 00:37:11 -07:00
Feross Aboukhadijeh
994aed9af7 Fix "Cannot read property 'files' of null"
This PR fixes one of our number 2 top error (142 error reports today
alone):

Processes: webtorrent window, platforms: darwin linux win32, versions:
pre-0.12 0.14.0 0.17.0 0.17.1
TypeError: Cannot read property 'files' of null
    at getAudioMetadata (.../build/renderer/webtorrent.js:328:21)
    at EventEmitter.<anonymous> (.../build/renderer/webtorrent.js:84:74)
    at emitThree (events.js:116:13)
    at EventEmitter.emit (events.js:194:7)

This error is reproducible if you start webtorrent for the first time
and click the WIRED CD torrent. This causes the webtorrent process to
get a  'wt-get-audio-metadata' before 'wt-start-torrenting'.

You can reproduce it 100% of the time if you force the race condition
to show itself by slowing down the sending of the 'wt-start-torrenting'
event.

(This same error was showing for an unrelated reason in the past: #891)
2016-10-05 03:00:52 -07:00
Feross Aboukhadijeh
852fc86cbd Remove unecessary return statement 2016-10-05 03:00:52 -07:00
Feross Aboukhadijeh
8801a87a58 Throttle browser-window 'move' and 'resize' events
Fixes: https://github.com/feross/webtorrent-desktop/issues/1011
2016-10-05 03:00:42 -07:00
Feross Aboukhadijeh
1e10f0083c Windows: Fix impossible to delete Wired CS torrent 2016-10-05 03:00:24 -07:00
Feross Aboukhadijeh
bb40f0f11a Update Mac integration test for Sierra 10.12
The font changed slightly on the next version of Mac OS. Let's update
the screenshots to match, since I already updated. (@dcposch - you'll
need to update too if you want the integration tests to pass on your
machine)
2016-10-04 01:53:49 -07:00
Feross Aboukhadijeh
78d7243a08 package: Explicitly specify architectures to build for
Electron now offers a "armv7l" build on Linux. Because we were
specifying "all", we were generating an armv7l binary which was being
included in "WebTorrent-vX.X.X-linux.zip" along with the ia32 build,
which explains why it was 90MB+ in the last release.
2016-10-03 04:40:42 -07:00
Feross Aboukhadijeh
09724dddd9 0.17.1 2016-10-03 04:02:21 -07:00
Feross Aboukhadijeh
59286ff3cb style: prefix all test commands with "test-"
It's a style I follow in all my packages...

- test
- test-node
- test-browser
- ...
2016-10-03 02:59:50 -07:00
Feross Aboukhadijeh
3f79c90868 Make Electron a devDependency
I moved it from devDependencies to dependencies when we added the app
to npm. But now that that's gone, let's move it back.

Functionally, this causes no difference since electron-packager
automatically excludes `electron` and all devDependencies from the
packaged app.
2016-10-03 02:59:50 -07:00
Feross Aboukhadijeh
33c48d4dfb cp-file@4
Only change is dropped Node 0.10 and 0.12 support. Nice change because
it means we load 3 fewer dependencies.
2016-10-03 02:38:03 -07:00
Feross Aboukhadijeh
48fbcd7303 v0.17.1 changelog 2016-10-03 02:28:25 -07:00
Feross Aboukhadijeh
cdb7b6eb44 Style fixes to PR #995 based on feedback 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
90b72523b7 perf: Only require('./user-tasks') on windows 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
a1fd30f4f5 cache mui theme after it is generated 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
fcae064dbb perf: ~40ms improvement: Lazy load controllers and page components 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
5815d8efe7 Fix first run telemetry bug 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
0be8a73621 perf: 140ms improvement: Hook into require() to modify how certain modules load 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
76e1d7777c Prevent exception when quitting and delayedInit() gets called 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
e10a84b294 Refactor: telemetry sending
- Fix bug where approxNumTorrents and other stats were not refreshed
when getting sent on 12 hour interval
- Lazy require modules
- Move setInterval into renderer/main.js
- Remove low-level https usage, use simple-get
2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
5ff2d893b9 perf: 90ms improvement: Defer more code in renderer, load state earlier
By deferring more code in the renderer and loading state earlier, we
improve startup time by another 90ms!

Before: 507 unique requires (1270-1280ms)
After: 506 unique requires  (1180-1190ms)
2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
02f5dbb63f perf: 60ms improvement: Replace fs-extra with mkdirp/rimraf/cp-file
In Electron apps, the cost of large modules is very real.

fs-extra is very convenient, but removing it caused 50 fewer unique
files to be required(), resultin in 60ms faster startup!

Before: 557 unique requires (1330-1340ms)
After: 507 unique requires (1270-1280ms)
2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
d4cfc32c8d re-order scripts 2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
d5820063a1 Perf: lazy-load more require() calls in main process
Went from 36 unique require calls, to 31 calls after this commit.
2016-10-03 01:24:53 -07:00
Feross Aboukhadijeh
21de048738 Remove unneeded console.logs 2016-10-03 01:24:53 -07:00
Benjamin Tan
06cdac8121 Remove weird outline from “Create Torrent” button.
The outline was caused by the wrong class being applied to the button.

Closes #989.
2016-10-02 21:35:26 -07:00
Mathias Rasmussen
f1aa1bdd59 go back to list on path missing error 2016-10-02 19:00:28 -07:00
Feross Aboukhadijeh
44f621b4de external player: Only run special .app login on darwin 2016-10-02 19:00:00 -07:00
Feross Aboukhadijeh
fd1a1f0f7e On paste: Do not handle multiple newline separated torrent ids
If the user accidentally pastes something that's not a torrent id, then
they get one "Invalid torrent Id" error for each line of the text.

Sure, this removes a "feature", but it's a pretty surprising one. When
I added it, I was being too clever, IMO.

The trim code can be removed, because that's handled in
controllers.torrentList.addTorrent().
2016-10-02 18:58:44 -07:00
Feross Aboukhadijeh
a38b5220ac Trim extra spaces off pasted magnet links
Before this change, using the "Open Torrent Address" dialog to paste a
magnet link would fail with leading or trailing spaces.

Pasting on the torrent list page has always trimmed. So this PR just
makes it consistent.
2016-10-02 18:58:44 -07:00
Feross Aboukhadijeh
428a07101a style: fix inconsistent naming
We don't use "ID" anywhere else in the codebase.
2016-10-02 18:58:44 -07:00
Feross Aboukhadijeh
f1ef9daa8f Update Electron to 1.4.2
Changelog: https://github.com/electron/electron/releases/tag/v1.4.2
2016-10-02 18:58:03 -07:00
Feross Aboukhadijeh
46d5c9c9e9 sound: Reduce volume even further 2016-10-01 19:36:06 -07:00
Feross Aboukhadijeh
ecd877551e sound: remove preloading
Sound playing is basically instant -- I was over-engineering when I
added this.
2016-10-01 19:36:06 -07:00
Feross Aboukhadijeh
76e071e965 Style: Use let/const instead of var 2016-10-01 00:50:04 +02:00
Mathias Rasmussen
5623c1024e delay external player if server is not ready 2016-09-30 01:14:32 -07:00
Feross Aboukhadijeh
1a20155f66 Disable Windows delta updates
See inline comment for explanation.
2016-09-28 09:48:51 +02:00
Benjamin Tan
9a17313a0c Remove unnecessary files after removal of npm package. 2016-09-28 09:28:31 +02:00
Feross Aboukhadijeh
b3ec61ddd8 Make Portable App also a Silent App
Fixes two portable app bugs, to make the app fully "silent", not just
"portable". This means that not only are all data files stored in the
"Portable Settings" folder, but the app should leave no trace on the
host system.

- Disable Electron's single instance mode so no lock file is created in
"%APPDATA%\Roaming\WebTorrent".

- Put Electron crash files, and other electron files into the "Portable
Settings\Temp" folder instead of "%APPDATA%\Temp".
2016-09-28 09:26:26 +02:00
Benjamin Tan
7dcddf90e9 Remove .nodemonignore file.
Adding configuration files for every tool used will clutter up the
repository, especially for a configuration as simple as this.
2016-09-28 09:03:46 +02:00
Feross Aboukhadijeh
a94fdcae61 Fix "Download path missing" for Portable App first run
Create "Portable Settings/Downloads" folder to prevent "Download path
missing" warning.
2016-09-28 04:22:05 +02:00
Feross Aboukhadijeh
f9bb83815f Switch fs.statSync to fs.accessSync, which is faster 2016-09-27 22:59:00 +02:00
Feross Aboukhadijeh
dccaf16a02 Merge pull request #973 from feross/feross/style
Pixel values should use numbers, not strings
2016-09-27 20:58:53 +02:00
Feross Aboukhadijeh
f4ee831319 Pixel values should use numbers, not strings
React converts to string and adds 'px' automatically
2016-09-27 11:56:12 -07:00
Feross Aboukhadijeh
5bcf1c6379 Merge pull request #972 from feross/f/controlst
Don't show pointer cursor on torrent list checkbox
2016-09-27 20:54:53 +02:00
Feross Aboukhadijeh
d2eb87e821 Merge pull request #974 from feross/feross/portable
Fix Windows Portable App mode
2016-09-27 20:47:07 +02:00
Feross Aboukhadijeh
be08925eb4 Fix Windows Portable App mode
Fixes: #971

This is a perfect example of putting too many statements into a
try-catch block. My bad. I was trying to keep the code simple, but it
bit us here.

This happens because we were using IS_PRODUCTION, but the order of the
consts at the top are:

const IS_PORTABLE = isPortable()
const IS_PRODUCTION = isProduction()

So we're inside of isPortable() and referring to IS_PRODUCTION before
it's defined. This should have thrown an exception, since const does
not allow use-before-define, but we're transforming to ES5 with Babel.

Also, standard could have caught this, but we can't enable the
use-before-define rule until this bug is fixed:
https://github.com/feross/standard/issues/636

Basically, a perfect storm.
2016-09-26 22:54:59 -07:00
Feross Aboukhadijeh
5698c22e75 Don't show pointer cursor on torrent list checkbox
Native apps don't have the "hand" cursor
2016-09-26 17:28:45 -07:00
DC
2114532f62 Integration tests: fix on Windows 2016-09-25 19:58:11 -07:00
DC
815ba00a6b Integration tests: fix on Mac 2016-09-25 19:43:09 -07:00
DC
bcd9f046fb No more npm publish 2016-09-25 18:24:38 -07:00
DC
bf3b416662 changelog 2016-09-25 18:23:00 -07:00
DC
736832c4e3 authors 2016-09-25 18:23:00 -07:00
DC
f08c0995a2 0.17.0 2016-09-25 18:23:00 -07:00
DC
de8a4d1160 Design: smaller progress bar 2016-09-25 18:23:00 -07:00
DC
739c1f705e Design: torrent list expand and collapse handle 2016-09-25 18:22:53 -07:00
DC
a32889291f Design: don't show 'Paused' on new torrents 2016-09-25 17:57:15 -07:00
Noam Okman
1e7e4cafd4 Use the progress bar from Material UI (#967) 2016-09-25 15:42:07 -07:00
Feross Aboukhadijeh
d5bed6c50a Add more peer ID entropy (#960) 2016-09-23 12:14:50 -07:00
Dan Flettre
8f827c9aae avoid saving window bounds when on player (#964) 2016-09-23 12:09:06 -07:00
DC
24fe033e2f Design: bring back the old startup sound
Talked about it w @feross, the new one sounds kind of like an error or warning
2016-09-23 03:44:52 -07:00
DC
85d04f931b Design: update integration test screenshots 2016-09-23 03:42:13 -07:00
DC
41b111c8a8 Design: address PR comments 2016-09-23 03:33:16 -07:00
DC
72db60bb12 Player: handle case where torrent-to-HTTP server hasn't started yet 2016-09-23 02:59:57 -07:00
DC
076eb009b9 Fix bug where playback rate could go negative 2016-09-23 02:59:57 -07:00
DC
789bd0ce82 Don't hide header when moused over player controls 2016-09-23 02:59:52 -07:00
DC
5155fca0e4 Design: off white primary color 2016-09-23 02:59:48 -07:00
DC
3b6819f894 Unmount audio/video tag when exiting player 2016-09-23 02:59:48 -07:00
DC
0dd1683298 Remove play button spinner, go to player page immediately 2016-09-23 02:59:48 -07:00
DC
2788d7433b Design: torrent list 2016-09-23 02:59:48 -07:00
DC
504a2419f6 Design: remove extra CSS 2016-09-23 02:59:48 -07:00
DC
75e5316ba1 Design: no gradients, nicer default colors
Before, the gradient transparent black overlay made text hard to read in some cases. Torrents without a poster image showed up in blue-gray and didn't look good.
2016-09-23 02:59:47 -07:00
DC
3be018521a Torrent list redesign 2016-09-23 02:59:47 -07:00
Feross Aboukhadijeh
6d375d5b5b Wait for 'ready-to-show' event before showing window (#959)
This gets rid of the light gray to dark gray background color change on
the main window at startup. Makes the window show slightly later, but
it's gray for less time. Doesn't affect overall startup time. Feels
less jank IMO.

From the Electron docs:

> While loading the page, the 'ready-to-show' event will be emitted
when renderer process has done drawing for the first time, showing
window after this event will have no visual flash.
2016-09-23 02:59:22 -07:00
Feross Aboukhadijeh
5de39bd7e5 Merge pull request #957 from feross/f/perf
startup perf: Reduce require() calls
2016-09-23 04:38:12 +02:00
Feross Aboukhadijeh
a08d576851 startup perf: Reduce require() calls
Every require() that we do before the users sees UI reduces startup
time.

I used the following code (added to index.js) to log every require()
call in the main process:

```js
var Module = require('module')
var required = {}
Module.prototype.require = function (orig) {
  return function (id) {
    if (!required[id]) {
      required[id] = true
      console.log(`${id}   (from ${this.filename})`)
    }
    return orig.apply(this, arguments)
  }
}(Module.prototype.require)
```

From this I was able to learn that lots of modules were being required
that aren't actually used until later.

I also sent this related PR to eliminate another few require()s:
https://github.com/LinusU/node-application-config/pull/4

This increases startup time by 50ms.

We'll probably realize much bigger gains by following this same
procedure for the renderer process.
2016-09-22 16:33:26 -07:00
Feross Aboukhadijeh
b8bdf65514 Use subtler UI sounds (#945)
* Use subtler UI sounds

Fixes #858

* Second round of volume tweaks
2016-09-22 14:42:43 -07:00
Feross Aboukhadijeh
1c0c3d07ff Merge pull request #949 from feross/f/state
State cleanup, rename, & tweaks
2016-09-22 23:41:01 +02:00
Feross Aboukhadijeh
832980eb9a Update Electron to 1.4.1 (#955)
Changelog: https://github.com/electron/electron/releases/tag/v1.4.1

(Should fix the Spectron console issue that @dcposch reported.)
2016-09-22 14:39:32 -07:00
Feross Aboukhadijeh
7c158e9f2c Rename events to be consistent
- Make State.save() always throttle calls -- since that's the common
case.

- Immediate saves are now the exception, with State.saveImmediate().

- The function is called State.save(), so the dispatch event should be
'stateSave'.
2016-09-22 14:25:57 -07:00
Feross Aboukhadijeh
a98d22ed72 code style 2016-09-22 14:20:58 -07:00
Feross Aboukhadijeh
63b7d34e29 Merge pull request #954 from omrilitov/master
use arch to determine OS_SYSARCH
2016-09-22 23:07:20 +02:00
Omri Litov
67ae6061aa use arch to determine OS_SYSARCH 2016-09-22 23:29:02 +03:00
Feross Aboukhadijeh
a8a861260e main: Start loading state before app is ready (#952)
As mentioned in
https://github.com/feross/webtorrent-desktop/pull/827#discussion_r799219
59

We should load the state outside the app.on('ready') handler so there's
a chance it's ready by the time 'ready' fires.

This improves startup time by roughly 50ms on my Macbook 12".
2016-09-22 03:00:40 -07:00
Feross Aboukhadijeh
fc879d5801 Update electron-packager to v8 (#946)
* electron-packager@8

* package: updates for electron-packager 8
2016-09-22 02:42:43 -07:00
DC
2d2645e642 Integration test: announce list windows screenshot 2016-09-21 23:58:42 -07:00
DC
8e66f641ce Integration test: default announce list changed 2016-09-21 23:58:21 -07:00
DC
82c49b5fc5 Integration test: yet another round of fixes 2016-09-21 23:58:21 -07:00
DC
b57ee73035 Integration tests: extra wait in test-audio 2016-09-21 23:58:21 -07:00
Feross Aboukhadijeh
ed8f493b8b Merge pull request #950 from feross/f/config
Make config static, not dynamic
2016-09-21 22:46:30 +02:00
Feross Aboukhadijeh
9e853027da make config static, not dynamic
I don't think we should dynamically generate the config object -- it
makes it harder to figure out what's going on.
2016-09-21 13:08:32 -07:00
Feross Aboukhadijeh
1e05487acd state: Use dispatch instead of direct call 2016-09-21 11:48:23 -07:00
Feross Aboukhadijeh
167da9dfd5 Double wait time until quit
On my modern Macbook 12" I've run into "Saving state took too long.
Quitting.". We have users with spinning disk drives, so let's be a bit
more generous.
2016-09-21 11:46:41 -07:00
Feross Aboukhadijeh
46e138a376 state: Use debounce to throttle saves 2016-09-21 11:46:00 -07:00
DC
853db922f1 Integration test: windows screenshot fuzzy diff 2016-09-20 23:53:00 -07:00
DC
7bf51b11ee Integration test: screenshot timing 2016-09-20 23:51:38 -07:00
DC
3a286ae978 Integration test: wait for next song 2016-09-20 23:51:38 -07:00
DC
82245f0b5c Integration tests: README 2016-09-20 23:51:37 -07:00
DC
802a898394 Integration tests: offline by default 2016-09-20 23:51:37 -07:00
DC
2200fffa1e Fix integration tests on Windows 2016-09-20 23:51:37 -07:00
DC
f4b2e78e72 Fix Delete Data on Windows. Fixes #935 2016-09-20 23:51:37 -07:00
DC
ad1162c7de Integration tests on Windows 2016-09-20 23:51:33 -07:00
DC
ed4daeb560 Integration test reliability 2016-09-20 23:49:45 -07:00
Feross Aboukhadijeh
927ae16e4f Merge pull request #941 from feross/dc/perf
Fix a sad, sad bug that resulted in 100+MB config.json
2016-09-21 08:18:48 +02:00
Feross Aboukhadijeh
917c89542b Merge pull request #944 from feross/buble
Replace babel with bublé
2016-09-21 08:18:27 +02:00
Feross Aboukhadijeh
39570bd4d7 Replace babel with bublé
Pros of bubel over babel:

- No configuration (a la standard)
- Runs twice as fast, for quicker development
- Converts everything to ES5 (which is likely to be faster than ES6,
untested)
- Easy to swap Babel back in -- low commitment

Cons:
- Less battle-tested than Babel, but recommended by React core
developer so probably not too bad
- No babel plugin support, but we're not using that right now anyway.
Can switch back to babel if we need that later

BEFORE:

$ time npm run build

> webtorrent-desktop@0.16.0 build /Users/feross/code/webtorrent-desktop
> babel --quiet src --out-dir build

npm run build  3.07s user 0.27s system 115% cpu 2.902 total

AFTER:

$ time npm run build

> webtorrent-desktop@0.16.0 build /Users/feross/code/webtorrent-desktop
> buble src --output build

npm run build  1.38s user 0.16s system 114% cpu 1.343 total
2016-09-20 15:21:17 -07:00
DC
f368dfad81 Fix a sad, sad bug that resulted in 100+MB config.json 2016-09-19 22:31:26 -07:00
DC
205e17cc83 Merge pull request #937 from feross/dynamic-window-min-height
make WINDOW_MIN_HEIGHT use existing values
2016-09-19 19:30:05 -07:00
Dan Flettre
4e052c8059 make WINDOW_MIN_HEIGHT use existing values 2016-09-19 16:48:12 -05:00
Dan Flettre
0afecd6063 Merge pull request #827 from feross/window-position
remember window position
2016-09-19 16:04:46 -05:00
DC
41511c5615 Integration test: use /tmp 2016-09-18 19:17:07 -07:00
Feross Aboukhadijeh
b9d39e3c64 changelog 2016-09-18 03:03:45 -07:00
Feross Aboukhadijeh
c1f482a950 Fix app DMG image 2016-09-18 02:58:39 -07:00
Feross Aboukhadijeh
e424031ad9 Windows build path fix 2016-09-18 02:58:36 -07:00
Feross Aboukhadijeh
f43dc2fc98 Fix windows path rename 2016-09-18 02:11:23 -07:00
Feross Aboukhadijeh
9cdc73edce 0.16.0 2016-09-18 01:56:59 -07:00
Feross Aboukhadijeh
3d254fa075 changelog 2016-09-18 01:41:01 -07:00
Feross Aboukhadijeh
ed1e43015e Merge pull request #931 from feross/detect-arch
Add 64-bit Windows build
2016-09-18 10:37:05 +02:00
Feross Aboukhadijeh
e6cbbd73f0 Fix silly typo 2016-09-18 01:35:20 -07:00
Feross Aboukhadijeh
67dff7b38c Add sanity check 2016-09-18 01:33:51 -07:00
Feross Aboukhadijeh
ced67176a3 add changelog 2016-09-18 01:29:14 -07:00
Feross Aboukhadijeh
00ac8afe64 About window: Show 32-bit vs. 64-bit status 2016-09-18 01:22:16 -07:00
Feross Aboukhadijeh
a6964c4495 Change file name inside RELEASES-ia32 to match renamed file 2016-09-18 01:07:45 -07:00
Dan Flettre
aedbc3c32f remember window position 2016-09-18 00:29:37 -05:00
DC
6541291e0d Integration test: address PR comments 2016-09-17 20:35:54 -07:00
DC
711d274398 Integration test: mock cast, remove loading bar
This lets us use exact screenshots with no transparency.
2016-09-17 20:35:53 -07:00
DC
6c5861b9fc Test screenshots: Create Torrent raised button 2016-09-17 20:35:53 -07:00
DC
f7ab27f9fd Integration test: audio 2016-09-17 20:35:53 -07:00
DC
e4e789cc5b Integration test: screenshot compare ignoring transparency 2016-09-17 20:35:52 -07:00
DC
09b525fe58 Integration test: simplify offline mode 2016-09-17 20:35:52 -07:00
DC
9dabfc1367 Integration test: offline mode 2016-09-17 20:35:52 -07:00
DC
bdb733352a Integration test: video playback 2016-09-17 20:35:52 -07:00
DC
75a4655a0f Integration test: create torrents 2016-09-17 20:35:52 -07:00
DC
051c1516a0 Integration test: add existing torrent 2016-09-17 20:35:51 -07:00
DC
62c5b78358 Integration test: update README 2016-09-17 20:35:51 -07:00
DC
290913d07a Integration test: delete torrent + data 2016-09-17 20:35:51 -07:00
Feross Aboukhadijeh
77534d650a Add 64-bit Windows build
Right now all Windows users are running a 32-bit app, even if their OS
is 64-bit.

Here's the plan to improve things:

1. We release a 64-bit installer, in addition to the 32-bit installer.

2. We auto-detect in the browser when a visitor is on a 32-bit vs.
64-bit OS and try to offer them the right installer. When in doubt, we
give them the 32-bit installer since that's safest.

3. The auto-updater will return the right binaries for the architecture
the user is on. This means that all our existing users who have 64-bit
OSs but are running the 32-bit app will get updated to the 64-bit app
on the next update. Also, 64-bit users who accidentally download the
32-bit installer will also get the 64-bit app on the next update.

---

Other notes:

- We don't generate 32-bit delta files. See reasoning inline.
- The package script now deletes extraneous Squirrel files (i.e.
*.nupkg delta files for older versions of the app) which should make
uploading the right files to GitHub easier. :)

The binary file naming works like this:

- Most users are on 64-bit systems, so they get nice clean file names
that don't specify an architecture (WebTorrentSetup-v1.0.0.exe). The
32-bit build files have the same naming but contain the string "-ia32"
at the end. In a few years, we will be able to just stop producing the
32-bit build files entirely.

- This means that the "WebTorrent-v0.15.0-linux-x64.zip" linux build
file is changing to "WebTorrent-v0.15.0-linux.zip" to match the Windows
naming convention. The .deb installer files must contain to
architecture in order to install correctly, so those do not change.

- Mac is 100% 64-bit, so it does not change.
2016-09-17 19:37:50 -07:00
Feross Aboukhadijeh
a4c715e3f6 Merge pull request #928 from feross/detect-arch
Detect system architecture; send in update/telemetry
2016-09-17 16:21:46 -07:00
Feross Aboukhadijeh
7415d3cee5 Detect system architecture; send in update/telemetry
Detect the actual operating system CPU architecture. This is different
than `process.arch` which returns the architecture the binary was
compiled for.

This is just good info to have in the telemetry, but we're also sending
it in the update check so that eventually we can upgrade Windows 32-bit
apps to 64-bit, like Slack does.

Context:
https://github.com/feross/webtorrent-desktop/issues/873#issuecomment-247
722023
2016-09-16 19:24:21 -07:00
Feross Aboukhadijeh
e1ba9c89fe Merge pull request #922 from feross/raised
Use raised button for inline button
2016-09-16 15:45:20 -07:00
Feross Aboukhadijeh
0fcbe7369a Merge pull request #925 from feross/capture-frame
Use capture-frame package
2016-09-16 15:43:20 -07:00
Feross Aboukhadijeh
c8087b5b63 Merge pull request #926 from feross/electron-1.4.0
Electron 1.4.0
2016-09-16 15:42:44 -07:00
Feross Aboukhadijeh
065faca8eb Electron 1.4.0 2016-09-16 10:25:03 -07:00
Feross Aboukhadijeh
bcd6a38a05 Use capture-frame package
See: https://github.com/feross/capture-frame
Capture video screenshot from a `<video>` tag (at the current time)

Changes from our version:

- Added tests in Chrome/Firefox browsers.
- Use built-in TypeError (which is meant for bad arguments) instead of
custom IllegalArgumentError.
2016-09-16 10:14:39 -07:00
DC
fa67f9b82b changelog 2016-09-16 01:11:12 -07:00
DC
39acd0bd47 authors 2016-09-16 01:02:16 -07:00
DC
c549fcfc27 0.15.0 2016-09-16 01:01:32 -07:00
DC
45e838d4c3 Telemetry: add torrent stats 2016-09-16 00:54:07 -07:00
DC
64f49e4d4f Auto launch: start minimized on MacOS 2016-09-13 19:49:47 -07:00
DC
61caa90901 Auto launch: don't open a terminal on MacOS 2016-09-13 19:49:47 -07:00
Noam Okman
3e85289318 Pref: start automatically on login 2016-09-13 19:49:47 -07:00
Feross Aboukhadijeh
a629f287f0 Use raised button for inline button 2016-09-13 08:16:38 -07:00
Mathias Rasmussen
3a4906079b External player clean up (#914)
* minor `addSubtitles` clean up

* external player clean up
2016-09-12 17:46:48 -07:00
Kai Curtis
3edf21f457 Update mouse moved time on header hover (#919)
Previously, moving the mouse into the player window from the sides or
bottom would bring up the HUD, but moving the mouse in from the top
would not. With this commit, moving the mouse in from the top of the
window will also bring up the HUD.

Fixes feross/webtorrent-desktop#241
2016-09-12 17:46:23 -07:00
DC
785c44cd2a Integration test: torrent list 2016-09-08 23:55:37 -07:00
DC
1ad8a5313b Integration test: save failed screenshot comparisons 2016-09-08 19:13:14 -07:00
DC
967e161288 Integration test: screenshots 2016-09-08 19:10:28 -07:00
DC
fe8c3b190c Integration test: tape + spectron hello world 2016-09-08 19:10:28 -07:00
Benjamin Tan
993e7d77ad Fix notification click not working. (#912)
This was changed incorrectly in 2a1e987.
2016-09-08 16:16:43 -07:00
DC
e0be052df4 Fix Open Torrent Address modal
Fixes a bug introduced in 0.14.0: cicking OK works, but hitting Enter doesn't do anything
2016-09-07 13:28:49 -07:00
Adam Gotlib
d331bae548 Move error definitions to errors.js (#898) 2016-09-07 13:21:59 -07:00
Adam Gotlib
d88229694a Disable playback controls while in external player (#909) 2016-09-07 13:13:50 -07:00
Adam Gotlib
8da5b955d6 Make git ignore npm-debug.log (#896) 2016-09-05 20:04:21 -07:00
DC
54882679c1 Dedupe cast.js status handlers, fix #889 2016-09-04 15:10:39 -07:00
DC
f2007be1b0 Fix selectFiles error, fixes #891 2016-09-04 14:56:17 -07:00
DC
7a757f9e05 More info in torrentFileModtimes, fix #892 2016-09-04 14:27:22 -07:00
DC
678f961110 Fix Heading.js 2016-09-04 11:09:08 -07:00
DC
f0464c44fd Changelog fixes 2016-09-04 10:31:01 -07:00
DC
b323ee24c4 Design: forward/back buttons, remove bad CSS 2016-09-04 09:51:50 -07:00
DC
38aad2ee9c authors 2016-09-03 18:49:49 -07:00
DC
b69ca93d20 0.14.0 2016-09-03 18:49:49 -07:00
DC
e21a039e70 changelog 2016-09-03 18:49:49 -07:00
Adam Gotlib
11f8e428a0 Restore playback state when reopening player (#877) 2016-09-03 15:25:19 -07:00
Mathias Rasmussen
704455c432 Rename App.js -> app.js (#882) 2016-09-03 13:24:09 -07:00
Noam Okman
c25bee755c make "npm run watch" work on windows (#878) 2016-09-03 20:13:11 +02:00
Dan Flettre
373d598c29 Merge pull request #880 from feross/dc/design
Torrent list design
2016-09-02 21:30:36 -05:00
DC
b8effffa96 Delete unused defaultPlayFileIndex 2016-09-02 14:13:30 -07:00
DC
ca6a7917ce Design: progress bar styling 2016-09-02 12:25:55 -07:00
DC
033bdf7908 Design: removed hover/select brightness
All those gradients and brighness filters toggling on and off were getting annoying
2016-09-02 12:08:57 -07:00
DC
6fe03aa325 Material UI: upgrade modals
Also clean up the Create Torrent page, delete some redundant CSS, prevent click-and-drag inside a TextField from moving the whole window, and make all label and input fonts  a consistent 14px size.
2016-09-02 02:30:37 -07:00
DC
b93f41f564 Telemetry: fix stacktrace redaction 2016-09-01 20:05:37 -07:00
DC
3f6cc97a02 Style: no more var 2016-09-01 19:52:37 -07:00
DC
0bda5358bd Style: extra linting 2016-09-01 19:41:50 -07:00
DC
e0af554caa Material UI: consistent JSX style 2016-09-01 19:38:21 -07:00
DC
b98f8476f5 Material UI: make file names consistent 2016-09-01 19:38:21 -07:00
Adam Gotlib
30732305ff Add playlists feature (#871)
* Open multi-file torrents as playlists

* Add `repeat` and `shuffle` options to the player

* Autoplay first file in torrent

* replaces `pickFileToPlay` feature
* when reopening player, restores the most recently viewed file

* Add playlist navigation buttons to Windows thumbar

* Remove `repeat` and `shuffle` options

This reverts commit 9284122461.

* Play files in order they appear in torrent

* Clean up playlists code
2016-09-01 19:18:48 -07:00
Adam Gotlib
14102ab3e6 Fix error on Windows caused by setBadge (#867) 2016-09-01 16:00:57 -07:00
Mathias Rasmussen
df16b14586 early telemetry (#870) 2016-09-01 15:58:02 -07:00
Mathias Rasmussen
9dcbc1b1f6 ensure that torrent file metadata is present (#869) 2016-09-01 15:54:37 -07:00
PurgingPanda
d806fd502f Added progress bar to the metadata on the overview (#844)
* Added progress bar to Torrent metadata on the overview.

* Added progress bar to Torrent metadata on the overview.

* Made progress bar smaller

As proposed by dcposh.
2016-09-01 14:43:28 -07:00
DC
db650caf18 changelog 2016-08-31 18:54:02 -07:00
DC
2b6c9ffcdb 0.13.1 2016-08-31 18:49:18 -07:00
DC
1d4b8ab67d Fix Create Torrent 2016-08-31 17:32:22 -07:00
DC
6404168bee changelog 2016-08-31 16:23:45 -07:00
DC
6613366cff authors 2016-08-31 16:09:49 -07:00
DC
74349129f4 0.13.0 2016-08-31 16:09:41 -07:00
DC
cdc2c1d718 Fix Create Torrent file list 2016-08-31 15:53:00 -07:00
DC
f8cc155650 Material UI: finish Create Torrent 2016-08-31 03:35:27 -07:00
DC
b6bdeab50b depcheck: ignore generated code 2016-08-31 00:58:00 -07:00
DC
f528f6033f Preferences: fix Make Default 2016-08-31 00:54:31 -07:00
DC
ef1bc13c38 use depcheck (#841)
* use depcheck to replace our own check-dependencies script

* ignore packges used in npm scripts
2016-08-30 20:42:45 -07:00
DC
2f54feac74 Telemetry: handle undefined values (#851) 2016-08-30 20:22:49 -07:00
Alberto Miranda
44a0f760de Resuming paused torrents when streaming torrent finishes downloading (wt-done). 2016-08-30 12:09:23 -03:00
Mathias Rasmussen
75d30baaa5 Telemetry: handle undefined values 2016-08-29 03:08:54 +02:00
Noam Okman
990fb57839 ignore packges used in npm scripts 2016-08-27 21:40:04 +03:00
Noam Okman
1883341ddb use depcheck 2016-08-27 21:40:02 +03:00
DC
5f7cece6d1 Fix npm run package
A require() had the wrong case, which apparently works for `npm start` build but fails in the packaged app
2016-08-27 09:05:58 -07:00
Alberto Miranda
1e5ac1df9c removed logging 2016-08-27 03:56:08 -03:00
Alberto Miranda
4cdc3085ff not pausing active torrents when playing an already downloaded torrent. 2016-08-27 03:55:23 -03:00
Alberto Miranda
e483263d70 removed logging 2016-08-27 03:36:28 -03:00
Alberto Miranda
bd41bd4db8 added highest playback priority feature; added highest playback priority checkbox on preferences. 2016-08-27 03:35:53 -03:00
Feross Aboukhadijeh
89e77d34f4 Merge pull request #838 from feross/wt-init-timeend
fix: add missing console.timeEnd
2016-08-26 20:18:03 +02:00
Nate Goldman
5eeb8fd6fc fix: add missing console.timeEnd 2016-08-26 09:43:40 -07:00
Feross Aboukhadijeh
808fca031a standard 2016-08-25 17:57:40 -07:00
Feross Aboukhadijeh
6b3c1e3802 Merge pull request #836 from feross/dc/fixes
Telemetry: log version in errors
2016-08-25 15:34:21 +02:00
DC
f488ef7597 Telemetry: fix error logging bugs, [object Object] and [object HTMLMediaElement] 2016-08-25 05:56:46 -07:00
DC
2c179c7465 Telemetry: log version in errors 2016-08-25 03:50:13 -07:00
Mathias Rasmussen
f1cf37200e decrease setProgress updates (#833) 2016-08-25 04:16:30 +02:00
Feross Aboukhadijeh
d2da6881d6 Merge pull request #829 from feross/f/hat
Remove 'hat' dependency
2016-08-25 02:57:32 +02:00
Feross Aboukhadijeh
9c25de58de Merge pull request #830 from feross/f/imageoptim
ImageOptim: losslessly compress all images
2016-08-25 02:57:02 +02:00
Feross Aboukhadijeh
f9aeb676b4 ImageOptim: losslessly compress all images 2016-08-24 03:47:47 -07:00
Feross Aboukhadijeh
396d769bc8 Remove 'hat' dependency 2016-08-24 00:40:33 -07:00
Feross Aboukhadijeh
83901eecba Fixes for PR #825
cc @mathiasvr
2016-08-23 17:25:59 -07:00
Feross Aboukhadijeh
019728cff5 Merge pull request #825 from feross/m/path-error-buttons
Don't render radial progress on path-missing error
2016-08-24 02:20:36 +02:00
Feross Aboukhadijeh
a96241d151 Merge pull request #824 from feross/m/open-partial-files
Don't open partially downloaded files in external app
2016-08-24 02:12:47 +02:00
Feross Aboukhadijeh
ebc9771be5 Merge pull request #823 from feross/m/external-controls
Don't render player controls when using external player
2016-08-24 02:08:58 +02:00
Feross Aboukhadijeh
0c75bac364 Merge pull request #822 from feross/m/playback-sound
Prevent notification sound during playback
2016-08-24 02:05:06 +02:00
Feross Aboukhadijeh
3f7e2c1e4a remove dev tools log 2016-08-23 16:24:41 -07:00
Feross Aboukhadijeh
10bf38bdf0 Merge pull request #826 from feross/readme
Readme improvements
2016-08-24 01:22:17 +02:00
Feross Aboukhadijeh
02508d7d9e Merge pull request #821 from feross/m/fix-poster-image
fix player poster image css bug
2016-08-24 01:21:53 +02:00
Feross Aboukhadijeh
5cb295f722 readme 2016-08-23 16:19:16 -07:00
Feross Aboukhadijeh
b08d273996 Fixes for PR #817
See
https://github.com/feross/webtorrent-desktop/pull/817#discussion-diff-75
968122
2016-08-23 16:14:22 -07:00
Feross Aboukhadijeh
1e27d1803a Merge pull request #817 from feross/menu-error
Fix error with menus.
2016-08-24 01:12:45 +02:00
Mathias Rasmussen
9bc018cc02 don't render radial progress on path error 2016-08-23 22:54:01 +02:00
Mathias Rasmussen
73cdfc6d45 don't open partially downloaded files 2016-08-23 22:23:43 +02:00
Mathias Rasmussen
1afd650997 Don't render player controls for external player 2016-08-23 20:57:31 +02:00
Mathias Rasmussen
9c8eabb46c Prevent notification sound during playback 2016-08-23 20:43:47 +02:00
Mathias Rasmussen
b39884e68f fix player poster image css bug 2016-08-23 20:21:10 +02:00
Benjamin Tan
451d457426 Fix error with menus. 2016-08-23 22:42:05 +08:00
Feross Aboukhadijeh
82853aa017 more progress 2016-08-23 04:21:58 -07:00
Feross Aboukhadijeh
157226f75b create torrent page progress 2016-08-23 03:51:05 -07:00
Feross Aboukhadijeh
509691a85a fixes for new path structure 2016-08-23 03:06:03 -07:00
Feross Aboukhadijeh
8b3aee7e2d move pages to renderer/pages/ 2016-08-23 03:06:03 -07:00
Feross Aboukhadijeh
4025e669eb misc changes 2016-08-23 03:06:03 -07:00
Feross Aboukhadijeh
1a01d7ed92 Use Material UI; improve Preferences Page
New principles for our UI:

- Components should use inline styles whenever possible
- Let's shrink the size of main.css to < 100 lines over time so it just
contains typography and basic styles
- Always require just the individual component that is needed from
Material UI so that the whole library doesn't get loaded (important for
startup perf)
2016-08-23 03:06:03 -07:00
Feross Aboukhadijeh
b4976d27f2 update material icons font 2016-08-23 03:05:33 -07:00
Feross Aboukhadijeh
173d8444d7 Preferences page rehaul: use React components, UI improvements 2016-08-23 03:05:33 -07:00
Feross Aboukhadijeh
aa150b76a5 less janky startup 2016-08-23 03:05:33 -07:00
Feross Aboukhadijeh
e2b5e28e07 add npm run watch command 2016-08-23 03:05:33 -07:00
Feross Aboukhadijeh
1ad07d9977 gitignore folders 2016-08-23 03:05:33 -07:00
Feross Aboukhadijeh
8ba4056894 move main.css to css/main.css 2016-08-23 03:04:58 -07:00
Feross Aboukhadijeh
9ad0316dff ensure Segoe is only used on Windows 2016-08-23 02:57:09 -07:00
Feross Aboukhadijeh
854aae7dc5 Merge pull request #813 from avamsi/patch-1
Add 'Segoe UI'
2016-08-23 11:55:24 +02:00
DC
5b021cd42e Audio: support .m4a 2016-08-23 02:39:45 -07:00
Vamsi Krishna Avula
33417d9b7e add 'Segoe UI' 2016-08-23 14:19:07 +05:30
Feross Aboukhadijeh
275184214a Merge pull request #811 from JaKXz/chore/electron
Switch from electron-prebuilt to electron
2016-08-23 10:35:16 +02:00
Jason Kurian
1f9adbd3cf Switch from electron-prebuilt to electron 2016-08-23 04:32:09 -04:00
DC
092c207dce changelog 2016-08-23 00:31:01 -07:00
DC
603c24faed authors 2016-08-23 00:30:56 -07:00
173 changed files with 5209 additions and 2638 deletions

View File

@@ -1,3 +1,5 @@
<!-- DO NOT POST LINKS OR REFERENCES TO COPYRIGHTED CONTENT IN YOUR ISSUE. -->
**What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu) **What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu)
**What operating system and version?** **What operating system and version?**

7
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules node_modules/
build build/
dist dist/
npm-debug.log*

View File

@@ -1,4 +1,4 @@
language: node_js language: node_js
node_js: node_js:
- 'node' - 'node'
install: npm install standard install: npm install standard depcheck walk-sync

View File

@@ -10,7 +10,6 @@
- Romain Beaumont (romain.rom1@gmail.com) - Romain Beaumont (romain.rom1@gmail.com)
- Dan Flettre (fletd01@yahoo.com) - Dan Flettre (fletd01@yahoo.com)
- Liam Gray (liam.r.gray@gmail.com) - Liam Gray (liam.r.gray@gmail.com)
- grunjol (grunjol@users.noreply.github.com)
- Rémi Jouannet (remijouannet@users.noreply.github.com) - Rémi Jouannet (remijouannet@users.noreply.github.com)
- Evan Miller (miller.evan815@gmail.com) - Evan Miller (miller.evan815@gmail.com)
- Alex (alxmorais8@msn.com) - Alex (alxmorais8@msn.com)
@@ -28,5 +27,15 @@
- Adam Gotlib (gotlib.adam+dev@gmail.com) - Adam Gotlib (gotlib.adam+dev@gmail.com)
- Rémi Jouannet (remijouannet@gmail.com) - Rémi Jouannet (remijouannet@gmail.com)
- Andrea Tupini (tupini07@gmail.com) - Andrea Tupini (tupini07@gmail.com)
- grunjol (grunjol@gmail.com)
- Jason Kurian (jasonk92@gmail.com)
- Vamsi Krishna Avula (vamsi_ism@outlook.com)
- Noam Okman (noamokman@gmail.com)
- PurgingPanda (t3ch0wn3r@gmail.com)
- Kai Curtis (morecode@kcurtis.com)
- Omri Litov (omrilitov@gmail.com)
- Alexey Romanov (romanalexey@gmail.com)
- Karan Thakkar (karanjthakkar@gmail.com)
- Nuno Campos (nuno.campos@me.com)
#### Generated by bin/update-authors.sh. #### Generated by bin/update-authors.sh.

View File

@@ -1,21 +1,156 @@
# WebTorrent Desktop Version History # WebTorrent Desktop Version History
## v0.18.0
### Added
- Add a new "Transfers" menu for pausing or resuming all torrents (#1027)
### Changed
- Update Electron to 1.4.15
- Windows 32-bit: App can use 4GB of memory instead of just 2GB
- Fix "Portable App" writing crash reports to "%APPDATA%\Temp" (Windows)
- Updated WebTorrent engine to 0.98.5
- Fix issue where http web seeds would sometimes stall
- Don't send 'completed' event to tracker again if torrent is already complete
- Add more peer ID entropy
- Set user-agent header for tracker http requests
### Fixed
- Fix paste shortcut in tracker list on Create Torrent page (#1112)
- Auto-focus the 'OK' button in modal dialogs (#1058)
- Fix formatting issue in the speed stats on the Player page (#1039)
## v0.17.2 - 2016-10-10
### Fixed
- Windows: Fix impossible-to-delete "Wired CD" default torrent
- Throttle browser-window 'move' and 'resize' events
- Fix crash ("Cannot read property 'files' of null" error)
- Fix crash ("TypeError: Cannot read property 'startPiece' of undefined")
## v0.17.1 - 2016-10-03
### Changed
- Faster startup (improved by ~25%)
- Update Electron to 1.4.2
- Remove support for pasting multiple newline-separated magnet links
- Reduce UX sound volume
### Fixed
- Fix external player (VLC, etc.) opening before HTTP server was ready
- Windows (Portable App): Fix "Portable App" mode
- Write application support files to the "Portable Settings" folder
- Stop writing Electron "single instance" lock file to "%APPDATA%\Roaming\WebTorrent"
- Some temp data is still written to "%APPDATA%\Temp" (will be fixed in future version)
- Don't show pointer cursor on torrent list checkbox
- Trim extra whitespace from magnet links pasted into "Open Torrent Address" dialog
- Fix weird outline on 'Create Torrent' button
## v0.17.0 - 2016-09-23
### Added
- Remember window size and position
### Changed
- Torrent list redesign
- Quieter, more subtle sounds
- Got rid of the play button spinner, now goes to the player immediately
- Faster startup
### Fixed
- Fix bug where playback rate could go negative
- Don't hide header when moused over player controls
- Fix Delete Data File on Windows
- Fix a sad, sad bug that resulted in 100+ MB config files
- Fix app DMG background image
## v0.16.0 - 2016-09-18
### Added
- **Windows 64-bit support!** ([#931](https://github.com/webtorrent/webtorrent-desktop/pull/931))
- Existing 32-bit users will update to 64-bit automatically in next release
- 64-bit reduces likelihood of out-of-memory errors by increasing the address space
### Fixed
- Mac: Fix background image on .DMG
## v0.15.0 - 2016-09-16
### Added
- Option to start automatically on login
- Add integration tests
- Add more detailed telemetry to diagnose "buffer allocation failed"
### Changed
- Disable playback controls while in external player (#909)
### Fixed
- Fix several uncaught errors (#889, #891, #892)
- Update to the latest webtorrent.js, fixing some more uncaught errors
- Clicking on the "torrent finished" notification works again (#912)
## v0.14.0 - 2016-09-03
### Added
- Autoplay through all files in a torrent (#871)
- Torrents now have a progress bar (#844)
### Changed
- Modals now use Material UI
- Torrent list style improvements
### Fixed
- Fix App.js crash in Linux (#882)
- Fix error on Windows caused by `setBadge` (#867)
- Don't crash when restarting after adding a magnet link (#869)
- Restore playback state when reopening player (#877)
## v0.13.1 - 2016-08-31
### Fixed
- Fixed the Create Torrent page
## v0.13.0 - 2016-08-31
### Added
- Support .m4a audio
- Better telemetry: log error versions, report more types of errors
### Changed
- New look - Material UI. Rewrote Create Torrent and Preferences pages.
### Fixed
- Fixed telemetry [object Object] and [object HTMLMediaElement] bugs
- Don't render player controls when playing externally, eg in VLC
- Don't play notification sounds during media playback
## v0.12.0 - 2016-08-23
### Added
- Custom external media player
- Linux: add system-wide launcher and icons for Debian, including Ubuntu
### Changed
- Telemetry improvements: redact stacktraces, log app version
### Fixed
- Fix playback and download of default torrents ("missing path" error) (#804)
- Fix Delete Torrent + Data for newly added magnet links
- Fix jumpToTime error (#804)
## v0.11.0 - 2016-08-19 ## v0.11.0 - 2016-08-19
### Added ### Added
- New Preference to "Set WebTorrent as default handler for torrents and magnet links" (#771) - New Preference to "Set WebTorrent as default handler for torrents and magnet links" (#771)
- New Preference to "Always play in VLC" (#674) - New Preference to "Always play in VLC" (#674)
- Check for missing default download path and torrent folders on start up (#776) - Check for missing default download path and torrent folders on start up (#776)
### Changed ### Changed
- Do not automatically set WebTorrent as the default handler for torrents (#771) - Do not automatically set WebTorrent as the default handler for torrents (#771)
- Torrents can only be created from the home screen (#770) - Torrents can only be created from the home screen (#770)
- Update Electron to 1.3.3 (#772) - Update Electron to 1.3.3 (#772)
### Fixed ### Fixed
- Allow modifying the default tracker list on the Create Torrent page (#775) - Allow modifying the default tracker list on the Create Torrent page (#775)
- Prevent opening multiple stacked Preference windows or Create Torrent windows (#770) - Prevent opening multiple stacked Preference windows or Create Torrent windows (#770)
- Windows: Player window auto-resize does not match video aspect ratio (#565) - Windows: Player window auto-resize does not match video aspect ratio (#565)
@@ -24,13 +159,11 @@
## v0.10.0 - 2016-08-05 ## v0.10.0 - 2016-08-05
### Added ### Added
- Drag-and-drop magnet links (selected text) is now supported (#284) - Drag-and-drop magnet links (selected text) is now supported (#284)
- Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114) - Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114)
- Linux: Show badge count for completed torrent downloads - Linux: Show badge count for completed torrent downloads
### Changed ### Changed
- Change WebTorrent Desktop peer ID prefix to 'WD' to distinguish from WebTorrent in the browser, 'WW' (#688) - Change WebTorrent Desktop peer ID prefix to 'WD' to distinguish from WebTorrent in the browser, 'WW' (#688)
- Switch UI to React to improve UI rendering speed (#729) - Switch UI to React to improve UI rendering speed (#729)
- The primary bottleneck was actually `hyperx`, not `virtual-dom`. - The primary bottleneck was actually `hyperx`, not `virtual-dom`.
@@ -47,7 +180,6 @@
- Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history) - Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history)
### Fixed ### Fixed
- When streaming to VLC, set VLC window title to torrent file name (#746) - When streaming to VLC, set VLC window title to torrent file name (#746)
- Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695) - Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695)
- Fix rare case where config file could not be completely written (#733) - Fix rare case where config file could not be completely written (#733)
@@ -55,35 +187,29 @@
## v0.9.0 - 2016-07-20 ## v0.9.0 - 2016-07-20
### Added ### Added
- Save selected subtitles - Save selected subtitles
- Ask for confirmation before deleting torrents - Ask for confirmation before deleting torrents
- Support Debian Jessie - Support Debian Jessie
### Changed ### Changed
- Only send telemetry in production - Only send telemetry in production
- Clean up the code. Split main.js, refactor lots of things - Clean up the code. Split main.js, refactor lots of things
### Fixed ### Fixed
- Fix state.playing.jumpToTime behavior - Fix state.playing.jumpToTime behavior
- Remove torrent file and poster image when deleting a torrent - Remove torrent file and poster image when deleting a torrent
## v0.8.1 - 2016-06-24 ## v0.8.1 - 2016-06-24
### Added ### Added
- New URI handler: stream-magnet - New URI handler: stream-magnet
### Fixed ### Fixed
- DLNA crashing bug - DLNA crashing bug
## v0.8.0 - 2016-06-23 ## v0.8.0 - 2016-06-23
### Added ### Added
- Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use - Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use
- Telemetry: send basic data, plus stats on how often the play button works - Telemetry: send basic data, plus stats on how often the play button works
- Make posters from jpeg files, not just jpg - Make posters from jpeg files, not just jpg
@@ -91,17 +217,14 @@
- Windows thumbnail bar with a play/pause button - Windows thumbnail bar with a play/pause button
### Changed ### Changed
- Nicer modal styles - Nicer modal styles
### Fixed ### Fixed
- Windows tray icon now stays in the right state - Windows tray icon now stays in the right state
## v0.7.2 - 2016-06-02 ## v0.7.2 - 2016-06-02
### Fixed ### Fixed
- Fix exception that affects users upgrading from v0.5.1 or older - Fix exception that affects users upgrading from v0.5.1 or older
- Ensure `state.saved.prefs` configuration exists - Ensure `state.saved.prefs` configuration exists
- Fix window title on "About WebTorrent" window - Fix window title on "About WebTorrent" window
@@ -109,23 +232,19 @@
## v0.7.1 - 2016-06-02 ## v0.7.1 - 2016-06-02
### Changed ### Changed
- Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows) - Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows)
- Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows) - Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows)
### Fixed ### Fixed
- First time startup bug -- invalid torrent/poster paths - First time startup bug -- invalid torrent/poster paths
## v0.7.0 - 2016-06-02 ## v0.7.0 - 2016-06-02
### Added ### Added
- Improved AirPlay support -- using the new [`airplayer`](https://www.npmjs.com/package/airplayer) package - Improved AirPlay support -- using the new [`airplayer`](https://www.npmjs.com/package/airplayer) package
- Remember volume setting in player, for as long as the app is open - Remember volume setting in player, for as long as the app is open
### Changed ### Changed
- Add (+) button now also accepts non .torrent files and creates a torrent from - Add (+) button now also accepts non .torrent files and creates a torrent from
those files those files
- Show prompt text in title bar for open dialogs (OS X) - Show prompt text in title bar for open dialogs (OS X)
@@ -135,7 +254,6 @@
- Fix crash reporter not working (Windows) - Fix crash reporter not working (Windows)
### Fixed ### Fixed
- Re-enable WebRTC (web peers)! (OS X, Windows) - Re-enable WebRTC (web peers)! (OS X, Windows)
- Windows support was disabled in v0.6.1 to work around a bug in Electron - Windows support was disabled in v0.6.1 to work around a bug in Electron
- OS X support was disabled in v0.4.0 to work around a 100% CPU bug - OS X support was disabled in v0.4.0 to work around a 100% CPU bug
@@ -146,7 +264,6 @@
- Fix torrent loading message UI misalignment - Fix torrent loading message UI misalignment
### Known issues ### Known issues
- When upgrading to WebTorrent Desktop v0.7.0, some torrent metadata (file list, - When upgrading to WebTorrent Desktop v0.7.0, some torrent metadata (file list,
selected files, whether torrent is streamable) will be cleared. Just start the selected files, whether torrent is streamable) will be cleared. Just start the
torrent to re-populate the metadata. torrent to re-populate the metadata.
@@ -154,7 +271,6 @@
## v0.6.1 - 2016-05-26 ## v0.6.1 - 2016-05-26
### Fixed ### Fixed
- Disable WebRTC to work around Electron crash (Windows) - Disable WebRTC to work around Electron crash (Windows)
- Will be re-enabled in the next version of WebTorrent, which will be based on - Will be re-enabled in the next version of WebTorrent, which will be based on
the next version of Electron, where the bug is fixed. the next version of Electron, where the bug is fixed.
@@ -166,7 +282,6 @@
## v0.6.0 - 2016-05-24 ## v0.6.0 - 2016-05-24
### Added ### Added
- Added Preferences page to set Download folder - Added Preferences page to set Download folder
- Save video position, resume playback from saved position - Save video position, resume playback from saved position
- Add additional video player keyboard shortcuts (#275) - Add additional video player keyboard shortcuts (#275)
@@ -176,7 +291,6 @@
- Add announcement feature - Add announcement feature
### Changed ### Changed
- Nicer player UI - Nicer player UI
- Reduce startup jank, improve startup time (#568) - Reduce startup jank, improve startup time (#568)
- Cleanup unsupported codec detection (#569, #570) - Cleanup unsupported codec detection (#569, #570)
@@ -184,7 +298,6 @@
- Improve subtitle positioning (#551) - Improve subtitle positioning (#551)
### Fixed ### Fixed
- Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567) - Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
- Fix bugs in LocationHistory - Fix bugs in LocationHistory
- When player is active, and magnet link is pasted, go back to list - When player is active, and magnet link is pasted, go back to list
@@ -195,23 +308,19 @@
## v0.5.1 - 2016-05-18 ## v0.5.1 - 2016-05-18
### Fixed ### Fixed
- Fix auto-updater (OS X, Windows). - Fix auto-updater (OS X, Windows).
## v0.5.0 - 2016-05-17 ## v0.5.0 - 2016-05-17
### Added ### Added
- Select/deselect individual files to torrent. - Select/deselect individual files to torrent.
- Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu. - Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu.
- "Add Subtitle File..." menu item. - "Add Subtitle File..." menu item.
### Changed ### Changed
- When manually adding subtitle track(s), always switch to the new track. - When manually adding subtitle track(s), always switch to the new track.
### Fixed ### Fixed
- Magnet links throw exception on app launch. (OS X) - Magnet links throw exception on app launch. (OS X)
- Multi-file torrents would not seed in-place, were copied to Downloads folder. - Multi-file torrents would not seed in-place, were copied to Downloads folder.
- Missing 'About WebTorrent' menu item. (Windows) - Missing 'About WebTorrent' menu item. (Windows)
@@ -220,7 +329,6 @@
## v0.4.0 - 2016-05-13 ## v0.4.0 - 2016-05-13
### Added ### Added
- Better Windows support! - Better Windows support!
- Windows 32-bit build. - Windows 32-bit build.
- Windows Portable App build. - Windows Portable App build.
@@ -243,7 +351,6 @@
- New default torrent on first launch: The WIRED CD. - New default torrent on first launch: The WIRED CD.
### Changed ### Changed
- Improve app startup time by 40%. - Improve app startup time by 40%.
- UI tweaks: Reduce font size, reduce torrent list item height. - UI tweaks: Reduce font size, reduce torrent list item height.
- Add Playback menu for playback-related functionality. - Add Playback menu for playback-related functionality.
@@ -254,7 +361,6 @@
- Remove "Add Fake Airplay/Chromecast" menu items. - Remove "Add Fake Airplay/Chromecast" menu items.
### Fixed ### Fixed
- Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is - Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is
temporary. (OS X) temporary. (OS X)
- When fullscreen, make controls use the full window. (OS X) - When fullscreen, make controls use the full window. (OS X)
@@ -279,24 +385,20 @@ to this release!
## v0.3.3 - 2016-04-07 ## v0.3.3 - 2016-04-07
### Fixed ### Fixed
- App icon was incorrect (OS X) - App icon was incorrect (OS X)
## v0.3.2 - 2016-04-07 ## v0.3.2 - 2016-04-07
### Added ### Added
- Register WebTorrent as default handler for magnet links (OS X) - Register WebTorrent as default handler for magnet links (OS X)
### Changed ### Changed
- Faster startup time (50ms) - Faster startup time (50ms)
- Update Electron to 0.37.5 - Update Electron to 0.37.5
- Remove the white flash when loading pages and resizing the window - Remove the white flash when loading pages and resizing the window
- Fix crash when sending IPC messages - Fix crash when sending IPC messages
### Fixed ### Fixed
- 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) - Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0)
@@ -304,17 +406,14 @@ to this release!
## v0.3.1 - 2016-04-06 ## v0.3.1 - 2016-04-06
### Added ### Added
- Add crash reporter to torrent engine process - Add crash reporter to torrent engine process
### Fixed ### Fixed
- Fix cast screen background: cover, don't tile - Fix cast screen background: cover, don't tile
## v0.3.0 - 2016-04-06 ## v0.3.0 - 2016-04-06
### Added ### Added
- **Ubuntu/Debian support!** (.deb installer) - **Ubuntu/Debian support!** (.deb installer)
- **DLNA streaming support** - **DLNA streaming support**
- Add "File > Quit" menu item (Linux) - Add "File > Quit" menu item (Linux)
@@ -322,14 +421,12 @@ to this release!
- Crash reporting - Crash reporting
### Changed ### Changed
- On startup, do not re-verify files when timestamps are unchanged - On startup, do not re-verify files when timestamps are unchanged
- Moved torrent engine to an independent process, for better UI performance - Moved torrent engine to an independent process, for better UI performance
- Removed media queries (UI resizing based on window width) - Removed media queries (UI resizing based on window width)
- Improved Chromecast icon, when connected - Improved Chromecast icon, when connected
### Fixed ### Fixed
- "Download Complete" notification shows consistently - "Download Complete" notification shows consistently
- Create new torrents and seed them without copying to temporary folder - Create new torrents and seed them without copying to temporary folder
- Clicking the "Download Complete" notification will always activate app - Clicking the "Download Complete" notification will always activate app
@@ -348,7 +445,6 @@ Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
## v0.2.0 - 2016-03-29 ## v0.2.0 - 2016-03-29
### Added ### Added
- Minimise to tray (Windows, Linux) - Minimise to tray (Windows, Linux)
- Show spinner and download speed when player is stalled waiting for data - Show spinner and download speed when player is stalled waiting for data
- Highlight window on drag-and-drop - Highlight window on drag-and-drop
@@ -357,12 +453,10 @@ Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
Linux users need to download new versions manually. Linux users need to download new versions manually.
### Changed ### Changed
- Renamed WebTorrent.app to WebTorrent Desktop - Renamed WebTorrent.app to WebTorrent Desktop
- Add Cosmos Laundromat as a default torrent - Add Cosmos Laundromat as a default torrent
### Fixed ### Fixed
- Only capture media keys when player is active - Only capture media keys when player is active
- Update WebTorrent to 0.88.1 for performance improvements - Update WebTorrent to 0.88.1 for performance improvements
- When seeding, do not proactively connect to new peers - When seeding, do not proactively connect to new peers
@@ -422,7 +516,7 @@ Windows, and Linux. For now, we're only releasing binaries for OS X.
WebTorrent Desktop is in ALPHA and under very active development  expect lots more polish in WebTorrent Desktop 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 the coming weeks! If you know JavaScript and want to help us out, there's
[lots to do](https://github.com/feross/webtorrent-desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)! [lots to do](https://github.com/webtorrent/webtorrent-desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
### Features ### Features

View File

@@ -24,7 +24,7 @@ and to avoid style arguments. `npm test` runs `standard` automatically, so you d
to! to!
[standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg
[standard-url]: https://github.com/feross/standard [standard-url]: https://standardjs.com
## Project Governance ## Project Governance
@@ -36,7 +36,7 @@ standard guarded open source project.
There are a few basic ground-rules for contributors: There are a few basic ground-rules for contributors:
1. **No `--force` pushes** or modifying the Git history in any way. 1. **No `--force` pushes to master** or modifying history in any way. Rebasing and force pushing your own PR branch is fine.
2. **Non-master branches** should be used for ongoing work. 2. **Non-master branches** should be used for ongoing work.
3. **Significant modifications** like API changes should be subject to a **pull request** 3. **Significant modifications** like API changes should be subject to a **pull request**
to solicit feedback from other contributors. to solicit feedback from other contributors.
@@ -74,6 +74,99 @@ By making a contribution to this project, I certify that:
including my sign-off) is maintained indefinitely and may be redistributed consistent including my sign-off) is maintained indefinitely and may be redistributed consistent
with this project or the open source license(s) involved. with this project or the open source license(s) involved.
## Release Procedure
### 1. Create a new version
- Update `AUTHORS`
```
npm run update-authors
```
Commit if necessary. The commit message should be "authors".
- Write the changelog
You can use `git log --oneline <last version tag>..HEAD` to get a list of changes.
Summarize them concisely in `CHANGELOG.md`. The commit message should be "changelog".
- Update the version
```
npm version [major|minor|patch]
```
This creates both a commit and a git tag.
- Make a PR
Once the PR is reviewed, merge it:
```
git push origin <branch-name>:master
```
This makes it so that the commit hash on master matches the commit hash of the version tag.
Finally, run:
```
git push --tags
```
### 2. Create the release binaries
- On a Mac:
```
npm run package -- darwin --sign
npm run package -- linux --sign
```
- On Windows, or in a Windows VM:
```
npm run package -- win32 --sign
```
- Then, upload the release binaries to Github:
```
npm run gh-release
```
Follow the URL to a newly created Github release page. Manually upload the binaries from
`webtorrent-desktop/dist/`. Open the previous release in another tab, and make sure that you
are uploading the same set of files, no more, no less.
### 3. Test it
**This is the most important part.**
- Manually download the binaries for each platform from Github.
**Do not use your locally built binaries.** Modern OSs treat executables differently if they've
been downloaded, even though the files are byte for byte identical. This ensures that the
codesigning worked and is valid.
- Smoke test WebTorrent Desktop on each platform.
See Smoke Tests below for details. Open DevTools
on Windows and Mac, and ensure that the auto updater is running. If the auto updater does not
run, users will successfully auto update to this new version, and then be stuck there forever.
### 4. Ship it
- Update the website
Create a pull request in [webtorrent.io](https://github.com/webtorrent/webtorrent.io). Update
`config.js`, updating the desktop app version.
As soon as this PR is merged, Jenkins will automatically redeploy the WebTorrent website, and
hundreds of thousands of users around the world will start auto updating. **Merge with care.**
## Smoke Tests ## Smoke Tests
Before a release, check that the following basic use cases work correctly: Before a release, check that the following basic use cases work correctly:

105
README.md
View File

@@ -1,45 +1,97 @@
<h1 align="center"> <h1 align="center">
<br> <br>
<a href="https://webtorrent.io"><img src="https://webtorrent.io/img/WebTorrent.png" alt="WebTorrent" width="200"></a> <a href="https://webtorrent.io">
<img src="https://webtorrent.io/img/WebTorrent.png" alt="WebTorrent" width="200">
</a>
<br> <br>
WebTorrent Desktop WebTorrent Desktop
<br> <br>
<br> <br>
</h1> </h1>
<h4 align="center">The streaming torrent client. For Mac, Windows, and Linux.</h4> <h4 align="center">The streaming torrent app. For Mac, Windows, and Linux.</h4>
<p align="center"> <p align="center">
<a href="https://gitter.im/feross/webtorrent"><img src="https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg" alt="Gitter"></a> <a href="https://gitter.im/webtorrent/webtorrent"><img src="https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg" alt="gitter"></a>
<a href="https://travis-ci.org/feross/webtorrent-desktop"><img src="https://img.shields.io/travis/feross/webtorrent-desktop/master.svg" alt="Travis"></a> <a href="https://github.com/webtorrent/webtorrent-desktop/releases"><img src="https://img.shields.io/github/release/webtorrent/webtorrent-desktop.svg" alt="github release"></a>
<a href="https://github.com/feross/webtorrent-desktop/releases"><img src="https://img.shields.io/github/release/feross/webtorrent-desktop.svg" alt="Release"></a> <a href="https://travis-ci.org/webtorrent/webtorrent-desktop"><img src="https://img.shields.io/travis/webtorrent/webtorrent-desktop/master.svg" alt="travis"></a>
<a href="https://standardjs.com"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Standard - JavaScript Style Guide"></a>
</p> </p>
## Install ## Install
**WebTorrent Desktop** is still under very active development. You can download the latest version from the [releases](https://github.com/feross/webtorrent-desktop/releases) page. Download the latest version of WebTorrent Desktop from
[the official website](https://webtorrent.io/desktop/) or the
[GitHub releases](https://github.com/webtorrent/webtorrent-desktop/releases) page.
## Screenshot **WebTorrent Desktop** is under very active development. You can try out the
current (unstable) development version by cloning the Git repo. See the
instructions below in the ["How to Contribute"](#how-to-contribute) section.
## Screenshots
<p align="center"> <p align="center">
<img src="https://webtorrent.io/img/screenshot-player3.png" alt="screenshot" align="center">
<img src="https://webtorrent.io/img/screenshot-main.png" width="612" height="749" alt="screenshot" align="center"> <img src="https://webtorrent.io/img/screenshot-main.png" width="612" height="749" alt="screenshot" align="center">
</p> </p>
## How to Contribute ## How to Contribute
### Install dependencies ### Get the code
``` ```
$ git clone https://github.com/webtorrent/webtorrent-desktop.git
$ cd webtorrent-desktop
$ npm install $ npm install
``` ```
### Run app ### Run the app
``` ```
$ npm start $ npm start
``` ```
### Package app ### Watch the code
Restart the app automatically every time code changes. Useful during development.
```
$ npm run watch
```
### Run linters
```
$ npm test
```
### Run integration tests
```
$ npm run test-integration
```
The integration tests use Spectron and Tape. They click through the app, taking screenshots and
comparing each one to a reference. Why screenshots?
* Ad-hoc checking makes the tests a lot more work to write
* Even diffing the whole HTML is not as thorough as screenshot diffing. For example, it wouldn't
catch an bug where hitting ESC from a video doesn't correctly restore window size.
* Chrome's own integration tests use screenshot diffing iirc
* Small UI changes will break a few tests, but the fix is as easy as deleting the offending
screenshots and running the tests, which will recreate them with the new look.
* The resulting Github PR will then show, pixel by pixel, the exact UI changes that were made! See
https://github.com/blog/817-behold-image-view-modes
For MacOS, you'll need a Retina screen for the integration tests to pass. Your screen should have
the same resolution as a 2016 12" Macbook.
For Windows, you'll need Windows 10 with a 1366x768 screen.
When running integration tests, keep the mouse on the edge of the screen and don't touch the mouse
or keyboard while the tests are running.
### Package the app
Builds app binaries for Mac, Linux, and Windows. Builds app binaries for Mac, Linux, and Windows.
@@ -50,7 +102,7 @@ $ npm run package
To build for one platform: To build for one platform:
``` ```
$ npm run package -- [platform] $ npm run package -- [platform] [options]
``` ```
Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default). Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default).
@@ -66,14 +118,18 @@ The following optional arguments are available:
- `portable` - Windows portable app - `portable` - Windows portable app
- `all` - All platforms (default) - `all` - All platforms (default)
Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, *-darwin.zip for Mac) will always be produced. Note: Even with the `--package` option, the auto-update files (.nupkg for Windows,
-darwin.zip for Mac) will always be produced.
#### Windows build notes #### Windows build notes
To package the Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs The Windows app can be packaged from **any** platform.
to be installed.
On Mac, first install [XQuartz](http://www.xquartz.org/), then run: Note: Windows code signing only works from **Windows**, for now.
Note: To package the Windows app from non-Windows platforms,
[Wine](https://www.winehq.org/) needs to be installed. For example on Mac, first
install [XQuartz](http://www.xquartz.org/), then run:
``` ```
brew install wine brew install wine
@@ -81,15 +137,22 @@ brew install wine
(Requires the [Homebrew](http://brew.sh/) package manager.) (Requires the [Homebrew](http://brew.sh/) package manager.)
#### Mac build notes
The Mac app can only be packaged from **macOS**.
#### Linux build notes
The Linux app can be packaged from **any** platform.
### Privacy ### Privacy
WebTorrent Desktop collects some basic usage stats to help us make the app better. For example, we track how well the play button works. How often does it succeed? Time out? Show a missing codec error? WebTorrent Desktop collects some basic usage stats to help us make the app better.
For example, we track how well the play button works. How often does it succeed?
Time out? Show a missing codec error?
The app never sends personally identifying information, nor does it track which swarms you join. The app never sends any personally identifying information, nor does it track which
torrents you add.
### Code Style
[![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard)
## License ## License

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env node
var fs = require('fs')
var cp = require('child_process')
// We can't use `builtin-modules` here since our TravisCI
// setup expects this file to run with no dependencies
var BUILT_IN_NODE_MODULES = [
'assert',
'buffer',
'child_process',
'cluster',
'console',
'constants',
'crypto',
'dgram',
'dns',
'domain',
'events',
'fs',
'http',
'https',
'module',
'net',
'os',
'path',
'process',
'punycode',
'querystring',
'readline',
'repl',
'stream',
'string_decoder',
'timers',
'tls',
'tty',
'url',
'util',
'v8',
'vm',
'zlib'
]
var BUILT_IN_ELECTRON_MODULES = [ 'electron' ]
var BUILT_IN_DEPS = [].concat(BUILT_IN_NODE_MODULES, BUILT_IN_ELECTRON_MODULES)
var EXECUTABLE_DEPS = [
'gh-release',
'standard',
'babel-cli',
'babel-plugin-syntax-jsx',
'babel-plugin-transform-react-jsx'
]
main()
// Scans codebase for missing or unused dependencies. Exits with code 0 on success.
function main () {
if (process.platform === 'win32') {
console.error('Sorry, check-deps only works on Mac and Linux')
return
}
var usedDeps = findUsedDeps()
var packageDeps = findPackageDeps()
var missingDeps = usedDeps.filter(
(dep) => !includes(packageDeps, dep) && !includes(BUILT_IN_DEPS, dep)
)
var unusedDeps = packageDeps.filter(
(dep) => !includes(usedDeps, dep) && !includes(EXECUTABLE_DEPS, dep)
)
if (missingDeps.length > 0) {
console.error('Missing package dependencies: ' + missingDeps)
}
if (unusedDeps.length > 0) {
console.error('Unused package dependencies: ' + unusedDeps)
}
if (missingDeps.length + unusedDeps.length > 0) {
process.exitCode = 1
}
}
// Finds all dependencies specified in `package.json`
function findPackageDeps () {
var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'))
var deps = Object.keys(pkg.dependencies)
var devDeps = Object.keys(pkg.devDependencies)
var optionalDeps = Object.keys(pkg.optionalDependencies)
return [].concat(deps, devDeps, optionalDeps)
}
// Finds all dependencies that used with `require()`
function findUsedDeps () {
var stdout = cp.execSync('./bin/list-deps.sh')
return stdout.toString().trim().split('\n')
}
function includes (arr, elem) {
return arr.indexOf(elem) >= 0
}

View File

@@ -5,13 +5,13 @@
* Useful for developers. * Useful for developers.
*/ */
var fs = require('fs') const fs = require('fs')
var os = require('os') const os = require('os')
var path = require('path') const path = require('path')
var rimraf = require('rimraf') const rimraf = require('rimraf')
var config = require('../src/config') const config = require('../src/config')
var handlers = require('../src/main/handlers') const handlers = require('../src/main/handlers')
// First, remove generated files // First, remove generated files
rimraf.sync('build/') rimraf.sync('build/')
@@ -21,11 +21,11 @@ rimraf.sync('dist/')
rimraf.sync(config.CONFIG_PATH) rimraf.sync(config.CONFIG_PATH)
// Remove any temporary files // Remove any temporary files
var tmpPath let tmpPath
try { try {
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent') tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
} catch (err) { } catch (err) {
tmpPath = path.join(os.tmpDir(), 'webtorrent') tmpPath = path.join(os.tmpdir(), 'webtorrent')
} }
rimraf.sync(tmpPath) rimraf.sync(tmpPath)

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env node
var electron = require('electron-prebuilt')
var cp = require('child_process')
var path = require('path')
var child = cp.spawn(electron, [path.join(__dirname, '..')], {stdio: 'inherit'})
child.on('close', function (code) {
process.exitCode = code
})

46
bin/extra-lint.js Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const walkSync = require('walk-sync')
const fs = require('fs')
const path = require('path')
let hasErrors = false
// Find all Javascript source files
const files = walkSync('src', {globs: ['**/*.js']})
console.log('Running extra-lint on ' + files.length + ' files...')
// Read each file, line by line
files.forEach(function (file) {
const filepath = path.join('src', file)
const lines = fs.readFileSync(filepath, 'utf8').split('\n')
lines.forEach(function (line, i) {
let error
// Consistent JSX tag closing
if (line.match(/' {2}\/> *$/) ||
line.match('[^ ]/> *$') ||
line.match(' > *$')) {
error = 'JSX tag spacing'
}
// No lines over 100 characters
if (line.length > 100) {
error = 'Line >100 chars'
}
if (line.match(/^var /) || line.match(/ var /)) {
error = 'Use const or let'
}
if (error) {
let name = path.basename(file)
console.log('%s:%d - %s:\n%s', name, i + 1, error, line)
hasErrors = true
}
})
})
if (hasErrors) process.exit(1)
else console.log('Looks good!')

View File

@@ -1,10 +0,0 @@
#!/bin/sh
# This is a truly heinous hack, but it works pretty nicely.
# Find all modules we're requiring---even conditional requires.
grep "require('" src/ bin/ -R |
grep '.js:' |
sed "s/.*require('\([^'\/]*\).*/\1/" |
grep -v '^\.' |
sort |
uniq

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
var config = require('../src/config') const config = require('../src/config')
var open = require('open') const open = require('open')
open(config.CONFIG_PATH) open(config.CONFIG_PATH)

View File

@@ -1,28 +1,29 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Builds app binaries for Mac, Linux, and Windows. * Builds app binaries for Mac, Windows, and Linux.
*/ */
var cp = require('child_process') const cp = require('child_process')
var electronPackager = require('electron-packager') const electronPackager = require('electron-packager')
var fs = require('fs') const fs = require('fs')
var minimist = require('minimist') const minimist = require('minimist')
var mkdirp = require('mkdirp') const mkdirp = require('mkdirp')
var os = require('os') const os = require('os')
var path = require('path') const path = require('path')
var rimraf = require('rimraf') const rimraf = require('rimraf')
var series = require('run-series') const series = require('run-series')
var zip = require('cross-zip') const zip = require('cross-zip')
var config = require('../src/config') const config = require('../src/config')
var pkg = require('../package.json') const pkg = require('../package.json')
var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
var BUILD_PATH = path.join(config.ROOT_PATH, 'build') const BUILD_PATH = path.join(config.ROOT_PATH, 'build')
var DIST_PATH = path.join(config.ROOT_PATH, 'dist') const DIST_PATH = path.join(config.ROOT_PATH, 'dist')
const NODE_MODULES_PATH = path.join(config.ROOT_PATH, 'node_modules')
var argv = minimist(process.argv.slice(2), { const argv = minimist(process.argv.slice(2), {
boolean: [ boolean: [
'sign' 'sign'
], ],
@@ -36,14 +37,20 @@ var argv = minimist(process.argv.slice(2), {
}) })
function build () { function build () {
console.log('Reinstalling node_modules...')
rimraf.sync(NODE_MODULES_PATH)
cp.execSync('npm install', { stdio: 'inherit' })
cp.execSync('npm dedupe', { stdio: 'inherit' })
console.log('Nuking dist/ and build/...')
rimraf.sync(DIST_PATH) rimraf.sync(DIST_PATH)
rimraf.sync(BUILD_PATH) rimraf.sync(BUILD_PATH)
console.log('Babel: Building JSX...') console.log('Build: Transpiling to ES5...')
cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' }) cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' })
console.log('Babel: Built JSX.') console.log('Build: Transpiled to ES5.')
var platform = argv._[0] const platform = argv._[0]
if (platform === 'darwin') { if (platform === 'darwin') {
buildDarwin(printDone) buildDarwin(printDone)
} else if (platform === 'win32') { } else if (platform === 'win32') {
@@ -61,7 +68,7 @@ function build () {
} }
} }
var all = { const all = {
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata // The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
// property on Windows, and `NSHumanReadableCopyright` on Mac. // property on Windows, and `NSHumanReadableCopyright` on Mac.
'app-copyright': config.APP_COPYRIGHT, 'app-copyright': config.APP_COPYRIGHT,
@@ -73,11 +80,11 @@ var all = {
// Package the application's source code into an archive, using Electron's archive // Package the application's source code into an archive, using Electron's archive
// format. Mitigates issues around long path names on Windows and slightly speeds up // format. Mitigates issues around long path names on Windows and slightly speeds up
// require(). // require().
asar: true, asar: {
// A glob expression, that unpacks the files with matching names to the
// A glob expression, that unpacks the files with matching names to the // "app.asar.unpacked" directory.
// "app.asar.unpacked" directory. unpack: 'WebTorrent*'
'asar-unpack': 'WebTorrent*', },
// The build version of the application. Maps to the FileVersion metadata property on // The build version of the application. Maps to the FileVersion metadata property on
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to // Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
@@ -89,7 +96,7 @@ var all = {
// Pattern which specifies which files to ignore when copying files to create the // Pattern which specifies which files to ignore when copying files to create the
// package(s). // package(s).
ignore: /^\/src|^\/dist|\/(appveyor.yml|\.appveyor.yml|\.github|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)$/, ignore: /^\/src|^\/dist|\/(appveyor.yml|\.appveyor.yml|\.github|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. // The application name.
name: config.APP_NAME, name: config.APP_NAME,
@@ -104,15 +111,15 @@ var all = {
// "devDependencies" before starting to package the app. // "devDependencies" before starting to package the app.
prune: true, prune: true,
// The Electron version with which the app is built (without the leading 'v') // The Electron version that the app is built with (without the leading 'v')
version: require('electron-prebuilt/package.json').version electronVersion: require('electron/package.json').version
} }
var darwin = { const darwin = {
// Build for Mac // Build for Mac
platform: 'darwin', platform: 'darwin',
// Build 64 bit binaries only. // Build x64 binaries only.
arch: 'x64', arch: 'x64',
// The bundle identifier to use in the application's plist (Mac only). // The bundle identifier to use in the application's plist (Mac only).
@@ -129,15 +136,15 @@ var darwin = {
icon: config.APP_ICON + '.icns' icon: config.APP_ICON + '.icns'
} }
var win32 = { const win32 = {
// Build for Windows. // Build for Windows.
platform: 'win32', platform: 'win32',
// Build 32 bit binaries only. // Build ia32 and x64 binaries.
arch: 'ia32', arch: ['ia32', 'x64'],
// Object hash of application metadata to embed into the executable (Windows only) // Object hash of application metadata to embed into the executable (Windows only)
'version-string': { win32metadata: {
// Company that produced the file. // Company that produced the file.
CompanyName: config.APP_NAME, CompanyName: config.APP_NAME,
@@ -163,12 +170,12 @@ var win32 = {
icon: config.APP_ICON + '.ico' icon: config.APP_ICON + '.ico'
} }
var linux = { const linux = {
// Build for Linux. // Build for Linux.
platform: 'linux', platform: 'linux',
// Build 32 and 64 bit binaries. // Build ia32 and x64 binaries.
arch: 'all' arch: ['ia32', 'x64']
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option. // Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
} }
@@ -176,18 +183,18 @@ var linux = {
build() build()
function buildDarwin (cb) { function buildDarwin (cb) {
var plist = require('plist') const plist = require('plist')
console.log('Mac: Packaging electron...') console.log('Mac: 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('Mac: Packaged electron. ' + buildPath) console.log('Mac: Packaged electron. ' + buildPath)
var appPath = path.join(buildPath[0], config.APP_NAME + '.app') const appPath = path.join(buildPath[0], config.APP_NAME + '.app')
var contentsPath = path.join(appPath, 'Contents') const contentsPath = path.join(appPath, 'Contents')
var resourcesPath = path.join(contentsPath, 'Resources') const resourcesPath = path.join(contentsPath, 'Resources')
var infoPlistPath = path.join(contentsPath, 'Info.plist') const infoPlistPath = path.join(contentsPath, 'Info.plist')
var infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8')) const infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
infoPlist.CFBundleDocumentTypes = [ infoPlist.CFBundleDocumentTypes = [
{ {
@@ -261,7 +268,7 @@ function buildDarwin (cb) {
} }
function signApp (cb) { function signApp (cb) {
var sign = require('electron-osx-sign') const sign = require('electron-osx-sign')
/* /*
* Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons: * Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons:
@@ -276,7 +283,7 @@ function buildDarwin (cb) {
* - Xcode Command Line Tools (xcode-select --install) * - Xcode Command Line Tools (xcode-select --install)
* - Membership in the Apple Developer Program * - Membership in the Apple Developer Program
*/ */
var signOpts = { const signOpts = {
app: appPath, app: appPath,
platform: 'darwin', platform: 'darwin',
verbose: true verbose: true
@@ -302,8 +309,8 @@ function buildDarwin (cb) {
// Create .zip file (used by the auto-updater) // Create .zip file (used by the auto-updater)
console.log('Mac: Creating zip...') console.log('Mac: Creating zip...')
var inPath = path.join(buildPath[0], config.APP_NAME + '.app') const inPath = path.join(buildPath[0], config.APP_NAME + '.app')
var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip') const outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log('Mac: Created zip.') console.log('Mac: Created zip.')
@@ -312,13 +319,13 @@ function buildDarwin (cb) {
function packageDmg (cb) { function packageDmg (cb) {
console.log('Mac: Creating dmg...') console.log('Mac: Creating dmg...')
var appDmg = require('appdmg') const appDmg = require('appdmg')
var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg') const targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
rimraf.sync(targetPath) rimraf.sync(targetPath)
// Create a .dmg (Mac disk image) file, for easy user installation. // Create a .dmg (Mac disk image) file, for easy user installation.
var dmgOpts = { const dmgOpts = {
basepath: config.ROOT_PATH, basepath: config.ROOT_PATH,
target: targetPath, target: targetPath,
specification: { specification: {
@@ -339,7 +346,7 @@ function buildDarwin (cb) {
} }
} }
var dmg = appDmg(dmgOpts) const dmg = appDmg(dmgOpts)
dmg.once('error', cb) dmg.once('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 + '...')
@@ -353,7 +360,7 @@ function buildDarwin (cb) {
} }
function buildWin32 (cb) { function buildWin32 (cb) {
var installer = require('electron-winstaller') const installer = require('electron-winstaller')
console.log('Windows: Packaging electron...') console.log('Windows: Packaging electron...')
/* /*
@@ -361,7 +368,7 @@ function buildWin32 (cb) {
* - Windows Authenticode private key and cert (authenticode.p12) * - Windows Authenticode private key and cert (authenticode.p12)
* - Windows Authenticode password file (authenticode.txt) * - Windows Authenticode password file (authenticode.txt)
*/ */
var CERT_PATH let CERT_PATH
try { try {
fs.accessSync('D:') fs.accessSync('D:')
CERT_PATH = 'D:' CERT_PATH = 'D:'
@@ -373,12 +380,12 @@ function buildWin32 (cb) {
if (err) return cb(err) if (err) return cb(err)
console.log('Windows: Packaged electron. ' + buildPath) console.log('Windows: Packaged electron. ' + buildPath)
var signWithParams let signWithParams
if (process.platform === 'win32') { if (process.platform === 'win32') {
if (argv.sign) { if (argv.sign) {
var certificateFile = path.join(CERT_PATH, 'authenticode.p12') const certificateFile = path.join(CERT_PATH, 'authenticode.p12')
var certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8') const certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
var timestampServer = 'http://timestamp.comodoca.com' const timestampServer = 'http://timestamp.comodoca.com'
signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256` signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
} else { } else {
printWarning() printWarning()
@@ -387,20 +394,26 @@ function buildWin32 (cb) {
printWarning() printWarning()
} }
var tasks = [] const tasks = []
if (argv.package === 'exe' || argv.package === 'all') { buildPath.forEach(function (filesPath) {
tasks.push((cb) => packageInstaller(cb)) const destArch = filesPath.split('-').pop()
}
if (argv.package === 'portable' || argv.package === 'all') { if (argv.package === 'exe' || argv.package === 'all') {
tasks.push((cb) => packagePortable(cb)) tasks.push((cb) => packageInstaller(filesPath, destArch, cb))
} }
if (argv.package === 'portable' || argv.package === 'all') {
tasks.push((cb) => packagePortable(filesPath, destArch, cb))
}
})
series(tasks, cb) series(tasks, cb)
function packageInstaller (cb) { function packageInstaller (filesPath, destArch, cb) {
console.log('Windows: Creating installer...') console.log(`Windows: Creating ${destArch} installer...`)
const archStr = destArch === 'ia32' ? '-ia32' : ''
installer.createWindowsInstaller({ installer.createWindowsInstaller({
appDirectory: buildPath[0], appDirectory: filesPath,
authors: config.APP_TEAM, authors: config.APP_TEAM,
description: config.APP_NAME, description: config.APP_NAME,
exe: config.APP_NAME + '.exe', exe: config.APP_NAME + '.exe',
@@ -410,8 +423,26 @@ function buildWin32 (cb) {
noMsi: true, noMsi: true,
outputDirectory: DIST_PATH, outputDirectory: DIST_PATH,
productName: config.APP_NAME, productName: config.APP_NAME,
remoteReleases: config.GITHUB_URL, /**
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe', * Only create delta updates for the Windows x64 build because 90% of our
* users have Windows x64 and the delta files take a *very* long time to
* generate. Also, the ia32 files on GitHub have non-standard Squirrel
* names (i.e. RELEASES-ia32 instead of RELEASES) and so Squirrel won't
* find them unless we proxy the requests.
*/
// TODO: Re-enable Windows 64-bit delta updates when we confirm that they
// work correctly in the presence of the "ia32" .nupkg files. I
// (feross) noticed them listed in the 64-bit RELEASES file and
// manually edited them out for the v0.17 release. Shipping only
// full updates for now will work fine, with no ill-effects.
// remoteReleases: destArch === 'x64'
// ? config.GITHUB_URL
// : undefined,
/**
* If you hit a "GitHub API rate limit exceeded" error, set this token!
*/
// remoteToken: process.env.WEBTORRENT_GITHUB_API_TOKEN,
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + archStr + '.exe',
setupIcon: config.APP_ICON + '.ico', setupIcon: config.APP_ICON + '.ico',
signWithParams: signWithParams, signWithParams: signWithParams,
title: config.APP_NAME, title: config.APP_NAME,
@@ -419,23 +450,71 @@ function buildWin32 (cb) {
version: pkg.version version: pkg.version
}) })
.then(function () { .then(function () {
console.log('Windows: Created installer.') console.log(`Windows: Created ${destArch} installer.`)
/**
* Delete extraneous Squirrel files (i.e. *.nupkg delta files for older
* versions of the app)
*/
fs.readdirSync(DIST_PATH)
.filter((name) => name.endsWith('.nupkg') && !name.includes(pkg.version))
.forEach((filename) => {
fs.unlinkSync(path.join(DIST_PATH, filename))
})
if (destArch === 'ia32') {
console.log('Windows: Renaming ia32 installer files...')
// RELEASES -> RELEASES-ia32
const relPath = path.join(DIST_PATH, 'RELEASES-ia32')
fs.renameSync(
path.join(DIST_PATH, 'RELEASES'),
relPath
)
// WebTorrent-vX.X.X-full.nupkg -> WebTorrent-vX.X.X-ia32-full.nupkg
fs.renameSync(
path.join(DIST_PATH, `${config.APP_NAME}-${config.APP_VERSION}-full.nupkg`),
path.join(DIST_PATH, `${config.APP_NAME}-${config.APP_VERSION}-ia32-full.nupkg`)
)
// Change file name inside RELEASES-ia32 to match renamed file
const relContent = fs.readFileSync(relPath, 'utf8')
const relContent32 = relContent.replace('full.nupkg', 'ia32-full.nupkg')
fs.writeFileSync(relPath, relContent32)
if (relContent === relContent32) {
// Sanity check
throw new Error('Fixing RELEASES-ia32 failed. Replacement did not modify the file.')
}
console.log('Windows: Renamed ia32 installer files.')
}
cb(null) cb(null)
}) })
.catch(cb) .catch(cb)
} }
function packagePortable (cb) { function packagePortable (filesPath, destArch, cb) {
console.log('Windows: Creating portable app...') console.log(`Windows: Creating ${destArch} portable app...`)
var portablePath = path.join(buildPath[0], 'Portable Settings') const portablePath = path.join(filesPath, 'Portable Settings')
mkdirp.sync(portablePath) mkdirp.sync(portablePath)
var inPath = path.join(DIST_PATH, path.basename(buildPath[0])) const downloadsPath = path.join(portablePath, 'Downloads')
var outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip') mkdirp.sync(downloadsPath)
const tempPath = path.join(portablePath, 'Temp')
mkdirp.sync(tempPath)
const archStr = destArch === 'ia32' ? '-ia32' : ''
const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, BUILD_NAME + '-win' + archStr + '.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log('Windows: Created portable app.') console.log(`Windows: Created ${destArch} portable app.`)
cb(null) cb(null)
} }
}) })
@@ -447,9 +526,9 @@ function buildLinux (cb) {
if (err) return cb(err) if (err) return cb(err)
console.log('Linux: Packaged electron. ' + buildPath) console.log('Linux: Packaged electron. ' + buildPath)
var tasks = [] const tasks = []
buildPath.forEach(function (filesPath) { buildPath.forEach(function (filesPath) {
var destArch = filesPath.split('-').pop() const destArch = filesPath.split('-').pop()
if (argv.package === 'deb' || argv.package === 'all') { if (argv.package === 'deb' || argv.package === 'all') {
tasks.push((cb) => packageDeb(filesPath, destArch, cb)) tasks.push((cb) => packageDeb(filesPath, destArch, cb))
@@ -465,8 +544,8 @@ function buildLinux (cb) {
// Create .deb file for Debian-based platforms // Create .deb file for Debian-based platforms
console.log(`Linux: Creating ${destArch} deb...`) console.log(`Linux: Creating ${destArch} deb...`)
var deb = require('nobin-debian-installer')() const deb = require('nobin-debian-installer')()
var destPath = path.join('/opt', pkg.name) const destPath = path.join('/opt', pkg.name)
deb.pack({ deb.pack({
package: pkg, package: pkg,
@@ -500,8 +579,10 @@ function buildLinux (cb) {
// Create .zip file for Linux // Create .zip file for Linux
console.log(`Linux: Creating ${destArch} zip...`) console.log(`Linux: Creating ${destArch} zip...`)
var inPath = path.join(DIST_PATH, path.basename(filesPath)) const archStr = destArch === 'ia32' ? '-ia32' : ''
var outPath = path.join(DIST_PATH, BUILD_NAME + '-linux-' + destArch + '.zip')
const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, BUILD_NAME + '-linux' + archStr + '.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log(`Linux: Created ${destArch} zip.`) console.log(`Linux: Created ${destArch} zip.`)

View File

@@ -1,10 +0,0 @@
#!/bin/sh
set -e
npm run update-authors
git diff --exit-code
npm run package -- --sign
git push
git push --tags
npm publish
./node_modules/.bin/gh-release

View File

@@ -1,8 +0,0 @@
#!/bin/sh
set -e
git pull
rm -rf node_modules/
npm install
npm dedupe
npm test

View File

@@ -1,7 +0,0 @@
#!/bin/sh
set -e
BIN=`dirname $0`
$BIN/release-_pre.sh
npm version major
$BIN/release-_post.sh

View File

@@ -1,7 +0,0 @@
#!/bin/sh
set -e
BIN=`dirname $0`
$BIN/release-_pre.sh
npm version minor
$BIN/release-_post.sh

View File

@@ -1,7 +0,0 @@
#!/bin/sh
set -e
BIN=`dirname $0`
$BIN/release-_pre.sh
npm version patch
$BIN/release-_post.sh

View File

@@ -12,6 +12,7 @@ while (<>) {
next if /(ungoldman\@gmail.com)/; next if /(ungoldman\@gmail.com)/;
next if /(dc\@DCs-MacBook.local)/; next if /(dc\@DCs-MacBook.local)/;
next if /(rolandoguedes\@gmail.com)/; next if /(rolandoguedes\@gmail.com)/;
next if /(grunjol\@users.noreply.github.com)/;
$seen{$_} = push @authors, "- ", $_; $seen{$_} = push @authors, "- ", $_;
} }
END { END {

View File

@@ -1,41 +1,50 @@
{ {
"name": "webtorrent-desktop", "name": "webtorrent-desktop",
"description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.", "description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.",
"version": "0.12.0", "version": "0.18.0",
"author": { "author": {
"name": "WebTorrent, LLC", "name": "WebTorrent, LLC",
"email": "feross@webtorrent.io", "email": "feross@webtorrent.io",
"url": "https://webtorrent.io" "url": "https://webtorrent.io"
}, },
"bin": {
"webtorrent-desktop": "./bin/cmd.js"
},
"bugs": { "bugs": {
"url": "https://github.com/feross/webtorrent-desktop/issues" "url": "https://github.com/webtorrent/webtorrent-desktop/issues"
}, },
"dependencies": { "dependencies": {
"airplayer": "^2.0.0", "airplayer": "^2.0.0",
"application-config": "^1.0.0", "application-config": "^1.0.0",
"arch": "^2.0.0",
"auto-launch": "^4.0.1",
"bitfield": "^1.0.2", "bitfield": "^1.0.2",
"capture-frame": "^1.0.0",
"chromecasts": "^1.8.0", "chromecasts": "^1.8.0",
"cp-file": "^4.0.1",
"create-torrent": "^3.24.5", "create-torrent": "^3.24.5",
"debounce": "^1.0.0",
"deep-equal": "^1.0.1", "deep-equal": "^1.0.1",
"dlnacasts": "^0.1.0", "dlnacasts": "^0.1.0",
"drag-drop": "^2.12.1", "drag-drop": "^2.12.1",
"electron-prebuilt": "1.3.3", "es6-error": "^4.0.0",
"fs-extra": "^0.30.0", "fn-getter": "^1.0.0",
"hat": "0.0.3", "gaze": "^1.1.2",
"iso-639-1": "^1.2.1", "iso-639-1": "^1.2.1",
"languagedetect": "^1.1.1", "languagedetect": "^1.1.1",
"location-history": "^1.0.0", "location-history": "^1.0.0",
"material-ui": "^0.17.0",
"mkdirp": "^0.5.1",
"ms": "^0.7.2",
"musicmetadata": "^2.0.2", "musicmetadata": "^2.0.2",
"network-address": "^1.1.0", "network-address": "^1.1.0",
"node-notifier": "^5.0.2",
"parse-torrent": "^5.7.3", "parse-torrent": "^5.7.3",
"prettier-bytes": "^1.0.1", "prettier-bytes": "^1.0.1",
"react": "^15.2.1", "react": "^15.4.2",
"react-dom": "^15.2.1", "react-dom": "^15.4.2",
"react-tap-event-plugin": "^2.0.1",
"rimraf": "^2.5.2",
"run-parallel": "^1.1.6", "run-parallel": "^1.1.6",
"semver": "^5.1.0", "semver": "^5.1.0",
"shell-env": "^0.3.0",
"simple-concat": "^1.0.0", "simple-concat": "^1.0.0",
"simple-get": "^2.0.0", "simple-get": "^2.0.0",
"srt-to-vtt": "^1.1.1", "srt-to-vtt": "^1.1.1",
@@ -45,22 +54,25 @@
"zero-fill": "^2.2.3" "zero-fill": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.11.4", "buble": "^0.15.2",
"babel-plugin-syntax-jsx": "^6.13.0",
"babel-plugin-transform-react-jsx": "^6.8.0",
"cross-zip": "^2.0.1", "cross-zip": "^2.0.1",
"electron-osx-sign": "^0.3.0", "depcheck": "^0.6.4",
"electron-packager": "^7.0.0", "electron": "1.6.0",
"electron-winstaller": "^2.3.0", "electron-osx-sign": "0.4.3",
"electron-packager": "~8.5.1",
"electron-winstaller": "~2.5.2",
"gh-release": "^2.0.3", "gh-release": "^2.0.3",
"minimist": "^1.2.0", "minimist": "^1.2.0",
"mkdirp": "^0.5.1", "nobin-debian-installer": "0.0.10",
"nobin-debian-installer": "^0.0.10", "nodemon": "^1.10.2",
"open": "0.0.5", "open": "0.0.5",
"plist": "^2.0.1", "plist": "^2.0.1",
"rimraf": "^2.5.2", "pngjs": "^3.0.0",
"run-series": "^1.1.4", "run-series": "^1.1.4",
"standard": "^8.0.0-beta.5" "spectron": "^3.3.0",
"standard": "*",
"tape": "^4.6.0",
"walk-sync": "^0.3.1"
}, },
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
@@ -81,19 +93,23 @@
"optionalDependencies": { "optionalDependencies": {
"appdmg": "^0.4.3" "appdmg": "^0.4.3"
}, },
"private": true,
"productName": "WebTorrent", "productName": "WebTorrent",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/feross/webtorrent-desktop.git" "url": "git://github.com/webtorrent/webtorrent-desktop.git"
}, },
"scripts": { "scripts": {
"build": "babel --quiet src --out-dir build", "build": "buble src --output build",
"clean": "node ./bin/clean.js", "clean": "node ./bin/clean.js",
"gh-release": "gh-release",
"open-config": "node ./bin/open-config.js", "open-config": "node ./bin/open-config.js",
"package": "node ./bin/package.js", "package": "node ./bin/package.js",
"prepublish": "npm run build", "prepublish": "npm run build",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"test": "standard && node ./bin/check-deps.js", "test": "standard && depcheck --ignores=buble,nodemon,gh-release --ignore-dirs=build,dist && node ./bin/extra-lint.js",
"update-authors": "./bin/update-authors.sh" "test-integration": "npm run build && node ./test",
"update-authors": "./bin/update-authors.sh",
"watch": "nodemon --exec \"npm run start\" --ext js,css --ignore build/ --ignore dist/"
} }
} }

View File

@@ -1,6 +0,0 @@
{
"plugins": [
"syntax-jsx",
"transform-react-jsx"
]
}

View File

@@ -1,20 +1,31 @@
var appConfig = require('application-config')('WebTorrent') const appConfig = require('application-config')('WebTorrent')
var fs = require('fs') const fs = require('fs')
var path = require('path') const path = require('path')
const electron = require('electron')
const arch = require('arch')
const gaze = require('gaze')
var APP_NAME = 'WebTorrent' const APP_NAME = 'WebTorrent'
var APP_TEAM = 'WebTorrent, LLC' const APP_TEAM = 'WebTorrent, LLC'
var APP_VERSION = require('../package.json').version const APP_VERSION = require('../package.json').version
var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings') const IS_TEST = isTest()
const PORTABLE_PATH = IS_TEST
? path.join(process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp', 'WebTorrentTest')
: path.join(path.dirname(process.execPath), 'Portable Settings')
const IS_PRODUCTION = isProduction()
const IS_PORTABLE = isPortable()
module.exports = { const UI_HEADER_HEIGHT = 38
const UI_TORRENT_HEIGHT = 100
const exports = module.exports = {
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement', ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update', AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report', CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry', TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM, APP_COPYRIGHT: 'Copyright © 2014-2017 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'), APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'), APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
APP_NAME: APP_NAME, APP_NAME: APP_NAME,
@@ -26,27 +37,32 @@ module.exports = {
DEFAULT_TORRENTS: [ DEFAULT_TORRENTS: [
{ {
testID: 'bbb',
name: 'Big Buck Bunny', name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg', posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent' torrentFileName: 'bigBuckBunny.torrent'
}, },
{ {
testID: 'cosmos',
name: 'Cosmos Laundromat (Preview)', name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg', posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent' torrentFileName: 'cosmosLaundromat.torrent'
}, },
{ {
testID: 'sintel',
name: 'Sintel', name: 'Sintel',
posterFileName: 'sintel.jpg', posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent' torrentFileName: 'sintel.torrent'
}, },
{ {
testID: 'tears',
name: 'Tears of Steel', name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg', posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent' torrentFileName: 'tearsOfSteel.torrent'
}, },
{ {
name: 'The WIRED CD - Rip. Sample. Mash. Share.', testID: 'wired',
name: 'The WIRED CD - Rip. Sample. Mash. Share',
posterFileName: 'wiredCd.jpg', posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent' torrentFileName: 'wiredCd.torrent'
} }
@@ -56,14 +72,17 @@ module.exports = {
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(), DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop', GITHUB_URL: 'https://github.com/webtorrent/webtorrent-desktop',
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues', GITHUB_URL_ISSUES: 'https://github.com/webtorrent/webtorrent-desktop/issues',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master', GITHUB_URL_RAW: 'https://raw.githubusercontent.com/webtorrent/webtorrent-desktop/master',
HOME_PAGE_URL: 'https://webtorrent.io', HOME_PAGE_URL: 'https://webtorrent.io',
IS_PORTABLE: isPortable(), IS_PORTABLE: IS_PORTABLE,
IS_PRODUCTION: isProduction(), IS_PRODUCTION: IS_PRODUCTION,
IS_TEST: IS_TEST,
OS_SYSARCH: arch() === 'x64' ? 'x64' : 'ia32',
POSTER_PATH: path.join(getConfigPath(), 'Posters'), POSTER_PATH: path.join(getConfigPath(), 'Posters'),
ROOT_PATH: path.join(__dirname, '..'), ROOT_PATH: path.join(__dirname, '..'),
@@ -74,12 +93,75 @@ module.exports = {
WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'), WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'),
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'), WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'),
WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents WINDOW_INITIAL_BOUNDS: {
WINDOW_MIN_WIDTH: 425 width: 500,
height: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 6) // header + 6 torrents
},
WINDOW_MIN_HEIGHT: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 2), // header + 2 torrents
WINDOW_MIN_WIDTH: 425,
UI_HEADER_HEIGHT: UI_HEADER_HEIGHT,
UI_TORRENT_HEIGHT: UI_TORRENT_HEIGHT
}
const configFile = appConfig.filePath
let config = getConfig()
const watchers = []
function updateConfig () {
config = JSON.parse(fs.readFileSync(configFile))
}
function watch () {
gaze(configFile, function (err) {
if (err) {
throw err
}
this.on('changed', () => {
try {
updateConfig()
console.log('WebTorrent configuration reloaded!')
watchers.forEach(fn => fn())
} catch (err) {
// TODO: display notification
console.log(`An error occurred loading your configuration (${configFile}): ${err.message}`)
}
})
this.on('error', () => {
// Ignore file watching errors
})
})
}
// start watching for config changes
watch()
exports.subscribe = function (fn) {
watchers.push(fn)
return () => {
watchers.splice(watchers.indexOf(fn), 1)
}
}
function getPlugins () {
return config.plugins || {}
}
exports.getPlugins = getPlugins
exports.getConfigPath = getConfigPath
exports.getConfig = getConfig
function getConfig () {
const config = {}
try {
return require(configFile)
} catch (e) {
return config
}
} }
function getConfigPath () { function getConfigPath () {
if (isPortable()) { if (IS_PORTABLE) {
return PORTABLE_PATH return PORTABLE_PATH
} else { } else {
return path.dirname(appConfig.filePath) return path.dirname(appConfig.filePath)
@@ -87,24 +169,45 @@ function getConfigPath () {
} }
function getDefaultDownloadPath () { function getDefaultDownloadPath () {
if (!process || !process.type) { if (IS_PORTABLE) {
return ''
}
if (isPortable()) {
return path.join(getConfigPath(), 'Downloads') return path.join(getConfigPath(), 'Downloads')
} else {
return getPath('downloads')
} }
}
var electron = require('electron') function getPath (key) {
if (!process.versions.electron) {
// Node.js process
return ''
} else if (process.type === 'renderer') {
// Electron renderer process
return electron.remote.app.getPath(key)
} else {
// Electron main process
return electron.app.getPath(key)
}
}
return process.type === 'renderer' function isTest () {
? electron.remote.app.getPath('downloads') return process.env.NODE_ENV === 'test'
: electron.app.getPath('downloads')
} }
function isPortable () { function isPortable () {
if (IS_TEST) {
return true
}
if (process.platform !== 'win32' || !IS_PRODUCTION) {
// Fast path: Non-Windows platforms should not check for path on disk
return false
}
try { try {
return process.platform === 'win32' && isProduction() && !!fs.statSync(PORTABLE_PATH) // This line throws if the "Portable Settings" folder does not exist, and does
// nothing otherwise.
fs.accessSync(PORTABLE_PATH, fs.constants.R_OK | fs.constants.W_OK)
return true
} catch (err) { } catch (err) {
return false return false
} }
@@ -112,6 +215,7 @@ function isPortable () {
function isProduction () { function isProduction () {
if (!process.versions.electron) { if (!process.versions.electron) {
// Node.js process
return false return false
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {

View File

@@ -2,10 +2,10 @@ module.exports = {
init init
} }
var config = require('./config')
var electron = require('electron')
function init () { function init () {
const config = require('./config')
const electron = require('electron')
electron.crashReporter.start({ electron.crashReporter.start({
companyName: config.APP_NAME, companyName: config.APP_NAME,
productName: config.APP_NAME, productName: config.APP_NAME,

View File

@@ -2,12 +2,12 @@ module.exports = {
init init
} }
var electron = require('electron') const electron = require('electron')
var config = require('../config') const config = require('../config')
var log = require('./log') const log = require('./log')
var ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL + const ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
'?version=' + config.APP_VERSION + '?version=' + config.APP_VERSION +
'&platform=' + process.platform '&platform=' + process.platform
@@ -26,7 +26,7 @@ var ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
* } * }
*/ */
function init () { function init () {
var get = require('simple-get') const get = require('simple-get')
get.concat(ANNOUNCEMENT_URL, onResponse) get.concat(ANNOUNCEMENT_URL, onResponse)
} }

View File

@@ -6,10 +6,10 @@ module.exports = {
openFiles openFiles
} }
var electron = require('electron') const electron = require('electron')
var log = require('./log') const log = require('./log')
var windows = require('./windows') const windows = require('./windows')
/** /**
* Show open dialog to create a single-file torrent. * Show open dialog to create a single-file torrent.
@@ -17,16 +17,11 @@ var windows = require('./windows')
function openSeedFile () { function openSeedFile () {
if (!windows.main.win) return if (!windows.main.win) return
log('openSeedFile') log('openSeedFile')
var opts = { const opts = {
title: 'Select a file for the torrent.', title: 'Select a file for the torrent.',
properties: [ 'openFile' ] properties: [ 'openFile' ]
} }
setTitle(opts.title) showOpenSeed(opts)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
} }
/* /*
@@ -37,7 +32,7 @@ function openSeedFile () {
function openSeedDirectory () { function openSeedDirectory () {
if (!windows.main.win) return if (!windows.main.win) return
log('openSeedDirectory') log('openSeedDirectory')
var opts = process.platform === 'darwin' const opts = process.platform === 'darwin'
? { ? {
title: 'Select a file or folder for the torrent.', title: 'Select a file or folder for the torrent.',
properties: [ 'openFile', 'openDirectory' ] properties: [ 'openFile', 'openDirectory' ]
@@ -46,12 +41,7 @@ function openSeedDirectory () {
title: 'Select a folder for the torrent.', title: 'Select a folder for the torrent.',
properties: [ 'openDirectory' ] properties: [ 'openDirectory' ]
} }
setTitle(opts.title) showOpenSeed(opts)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
} }
/* /*
@@ -61,7 +51,7 @@ function openSeedDirectory () {
function openFiles () { function openFiles () {
if (!windows.main.win) return if (!windows.main.win) return
log('openFiles') log('openFiles')
var opts = process.platform === 'darwin' const opts = process.platform === 'darwin'
? { ? {
title: 'Select a file or folder to add.', title: 'Select a file or folder to add.',
properties: [ 'openFile', 'openDirectory' ] properties: [ 'openFile', 'openDirectory' ]
@@ -84,7 +74,7 @@ function openFiles () {
function openTorrentFile () { function openTorrentFile () {
if (!windows.main.win) return if (!windows.main.win) return
log('openTorrentFile') log('openTorrentFile')
var opts = { const opts = {
title: 'Select a .torrent file.', title: 'Select a .torrent file.',
filters: [{ name: 'Torrent Files', extensions: ['torrent'] }], filters: [{ name: 'Torrent Files', extensions: ['torrent'] }],
properties: [ 'openFile', 'multiSelections' ] properties: [ 'openFile', 'multiSelections' ]
@@ -119,3 +109,16 @@ function setTitle (title) {
function resetTitle () { function resetTitle () {
windows.main.dispatch('resetTitle') windows.main.dispatch('resetTitle')
} }
/**
* Pops up an Open File dialog with the given options.
* After the user selects files / folders, shows the Create Torrent page.
*/
function showOpenSeed (opts) {
setTitle(opts.title)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
}

View File

@@ -4,19 +4,17 @@ module.exports = {
setBadge setBadge
} }
var electron = require('electron') const {app, Menu} = require('electron')
var app = electron.app const dialog = require('./dialog')
const log = require('./log')
var dialog = require('./dialog')
var log = require('./log')
/** /**
* Add a right-click menu to the dock icon. (Mac) * Add a right-click menu to the dock icon. (Mac)
*/ */
function init () { function init () {
if (!app.dock) return if (!app.dock) return
var menu = electron.Menu.buildFromTemplate(getMenuTemplate()) const menu = Menu.buildFromTemplate(getMenuTemplate())
app.dock.setMenu(menu) app.dock.setMenu(menu)
} }
@@ -33,8 +31,11 @@ function downloadFinished (path) {
* Display a counter badge for the app. (Mac, Linux) * Display a counter badge for the app. (Mac, Linux)
*/ */
function setBadge (count) { function setBadge (count) {
log(`setBadge: ${count}`) if (process.platform === 'darwin' ||
app.setBadgeCount(Number(count)) (process.platform === 'linux' && app.isUnityRunning())) {
log(`setBadge: ${count}`)
app.setBadgeCount(Number(count))
}
} }
function getMenuTemplate () { function getMenuTemplate () {

View File

@@ -4,29 +4,36 @@ module.exports = {
checkInstall checkInstall
} }
var cp = require('child_process') const cp = require('child_process')
var vlcCommand = require('vlc-command') const path = require('path')
const vlcCommand = require('vlc-command')
var log = require('./log') const log = require('./log')
var windows = require('./windows') const windows = require('./windows')
// holds a ChildProcess while we're playing a video in an external player, null otherwise // holds a ChildProcess while we're playing a video in an external player, null otherwise
var proc let proc = null
function checkInstall (path, cb) { function checkInstall (playerPath, cb) {
// check for VLC if external player has not been specified by the user // check for VLC if external player has not been specified by the user
// otherwise assume the player is installed // otherwise assume the player is installed
if (path == null) return vlcCommand((err) => cb(!err)) if (playerPath == null) return vlcCommand(cb)
process.nextTick(() => cb(true)) process.nextTick(() => cb(null))
} }
function spawn (path, url, title) { function spawn (playerPath, url, title) {
if (path != null) return spawnExternal(path, [url]) if (playerPath != null) return spawnExternal(playerPath, [url])
// Try to find and use VLC if external player is not specified // Try to find and use VLC if external player is not specified
vlcCommand(function (err, vlcPath) { vlcCommand(function (err, vlcPath) {
if (err) return windows.main.dispatch('externalPlayerNotFound') if (err) return windows.main.dispatch('externalPlayerNotFound')
var args = ['--play-and-exit', '--video-on-top', '--quiet', `--meta-title=${JSON.stringify(title)}`, url] const args = [
'--play-and-exit',
'--video-on-top',
'--quiet',
`--meta-title=${JSON.stringify(title)}`,
url
]
spawnExternal(vlcPath, args) spawnExternal(vlcPath, args)
}) })
} }
@@ -38,13 +45,18 @@ function kill () {
proc = null proc = null
} }
function spawnExternal (path, args) { function spawnExternal (playerPath, args) {
log('Running external media player:', path + ' ' + args.join(' ')) log('Running external media player:', playerPath + ' ' + args.join(' '))
proc = cp.spawn(path, args, {stdio: 'ignore'}) if (process.platform === 'darwin' && path.extname(playerPath) === '.app') {
// Mac: Use executable in packaged .app bundle
playerPath += '/Contents/MacOS/' + path.basename(playerPath, '.app')
}
proc = cp.spawn(playerPath, args, {stdio: 'ignore'})
// If it works, close the modal after a second // If it works, close the modal after a second
var closeModalTimeout = setTimeout(() => const closeModalTimeout = setTimeout(() =>
windows.main.dispatch('exitModal'), 1000) windows.main.dispatch('exitModal'), 1000)
proc.on('close', function (code) { proc.on('close', function (code) {

View File

@@ -3,8 +3,8 @@ module.exports = {
uninstall uninstall
} }
var config = require('../config') const config = require('../config')
var path = require('path') const path = require('path')
function install () { function install () {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
@@ -31,8 +31,8 @@ function uninstall () {
} }
function installDarwin () { function installDarwin () {
var electron = require('electron') const electron = require('electron')
var app = electron.app const app = electron.app
// On Mac, only protocols that are listed in `Info.plist` can be set as the // On Mac, only protocols that are listed in `Info.plist` can be set as the
// default handler at runtime. // default handler at runtime.
@@ -44,18 +44,18 @@ function installDarwin () {
function uninstallDarwin () {} function uninstallDarwin () {}
var EXEC_COMMAND = [ process.execPath ] const EXEC_COMMAND = [ process.execPath ]
if (!config.IS_PRODUCTION) { if (!config.IS_PRODUCTION) {
EXEC_COMMAND.push(config.ROOT_PATH) EXEC_COMMAND.push(config.ROOT_PATH)
} }
function installWin32 () { function installWin32 () {
var Registry = require('winreg') const Registry = require('winreg')
var log = require('./log') const log = require('./log')
var iconPath = path.join( const iconPath = path.join(
process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico' process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico'
) )
registerProtocolHandlerWin32( registerProtocolHandlerWin32(
@@ -100,7 +100,7 @@ function installWin32 () {
*/ */
function registerProtocolHandlerWin32 (protocol, name, icon, command) { function registerProtocolHandlerWin32 (protocol, name, icon, command) {
var protocolKey = new Registry({ const protocolKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + protocol key: '\\Software\\Classes\\' + protocol
}) })
@@ -120,7 +120,7 @@ function installWin32 () {
function setIcon (err) { function setIcon (err) {
if (err) log.error(err.message) if (err) log.error(err.message)
var iconKey = new Registry({ const iconKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + protocol + '\\DefaultIcon' key: '\\Software\\Classes\\' + protocol + '\\DefaultIcon'
}) })
@@ -130,7 +130,7 @@ function installWin32 () {
function setCommand (err) { function setCommand (err) {
if (err) log.error(err.message) if (err) log.error(err.message)
var commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command' key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
}) })
@@ -161,7 +161,7 @@ function installWin32 () {
setExt() setExt()
function setExt () { function setExt () {
var extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + ext key: '\\Software\\Classes\\' + ext
}) })
@@ -171,7 +171,7 @@ function installWin32 () {
function setId (err) { function setId (err) {
if (err) log.error(err.message) if (err) log.error(err.message)
var idKey = new Registry({ const idKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + id key: '\\Software\\Classes\\' + id
}) })
@@ -181,7 +181,7 @@ function installWin32 () {
function setIcon (err) { function setIcon (err) {
if (err) log.error(err.message) if (err) log.error(err.message)
var iconKey = new Registry({ const iconKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + id + '\\DefaultIcon' key: '\\Software\\Classes\\' + id + '\\DefaultIcon'
}) })
@@ -191,7 +191,7 @@ function installWin32 () {
function setCommand (err) { function setCommand (err) {
if (err) log.error(err.message) if (err) log.error(err.message)
var commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + id + '\\shell\\open\\command' key: '\\Software\\Classes\\' + id + '\\shell\\open\\command'
}) })
@@ -205,7 +205,7 @@ function installWin32 () {
} }
function uninstallWin32 () { function uninstallWin32 () {
var Registry = require('winreg') const Registry = require('winreg')
unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND) unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND)
unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND) unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND)
@@ -215,7 +215,7 @@ function uninstallWin32 () {
getCommand() getCommand()
function getCommand () { function getCommand () {
var commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command' key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
}) })
@@ -227,7 +227,7 @@ function uninstallWin32 () {
} }
function destroyProtocol () { function destroyProtocol () {
var protocolKey = new Registry({ const protocolKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + protocol key: '\\Software\\Classes\\' + protocol
}) })
@@ -239,7 +239,7 @@ function uninstallWin32 () {
eraseId() eraseId()
function eraseId () { function eraseId () {
var idKey = new Registry({ const idKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + id key: '\\Software\\Classes\\' + id
}) })
@@ -247,7 +247,7 @@ function uninstallWin32 () {
} }
function getExt () { function getExt () {
var extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: '\\Software\\Classes\\' + ext key: '\\Software\\Classes\\' + ext
}) })
@@ -259,7 +259,7 @@ function uninstallWin32 () {
} }
function destroyExt () { function destroyExt () {
var extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + ext key: '\\Software\\Classes\\' + ext
}) })
@@ -273,12 +273,12 @@ function commandToArgs (command) {
} }
function installLinux () { function installLinux () {
var fs = require('fs-extra') const fs = require('fs')
var os = require('os') const os = require('os')
var path = require('path') const path = require('path')
var config = require('../config') const config = require('../config')
var log = require('./log') const log = require('./log')
// Do not install in user dir if running on system // Do not install in user dir if running on system
if (/^\/opt/.test(process.execPath)) return if (/^\/opt/.test(process.execPath)) return
@@ -287,7 +287,7 @@ function installLinux () {
installIconFile() installIconFile()
function installDesktopFile () { function installDesktopFile () {
var templatePath = path.join( const templatePath = path.join(
config.STATIC_PATH, 'linux', 'webtorrent-desktop.desktop' config.STATIC_PATH, 'linux', 'webtorrent-desktop.desktop'
) )
fs.readFile(templatePath, 'utf8', writeDesktopFile) fs.readFile(templatePath, 'utf8', writeDesktopFile)
@@ -296,7 +296,7 @@ function installLinux () {
function writeDesktopFile (err, desktopFile) { function writeDesktopFile (err, desktopFile) {
if (err) return log.error(err.message) if (err) return log.error(err.message)
var appPath = config.IS_PRODUCTION const appPath = config.IS_PRODUCTION
? path.dirname(process.execPath) ? path.dirname(process.execPath)
: config.ROOT_PATH : config.ROOT_PATH
@@ -305,7 +305,7 @@ function installLinux () {
desktopFile = desktopFile.replace(/\$EXEC_PATH/g, EXEC_COMMAND.join(' ')) desktopFile = desktopFile.replace(/\$EXEC_PATH/g, EXEC_COMMAND.join(' '))
desktopFile = desktopFile.replace(/\$TRY_EXEC_PATH/g, process.execPath) desktopFile = desktopFile.replace(/\$TRY_EXEC_PATH/g, process.execPath)
var desktopFilePath = path.join( const desktopFilePath = path.join(
os.homedir(), os.homedir(),
'.local', '.local',
'share', 'share',
@@ -319,47 +319,51 @@ function installLinux () {
} }
function installIconFile () { function installIconFile () {
var iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png') const iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png')
fs.readFile(iconStaticPath, writeIconFile) fs.readFile(iconStaticPath, writeIconFile)
} }
function writeIconFile (err, iconFile) { function writeIconFile (err, iconFile) {
if (err) return log.error(err.message) if (err) return log.error(err.message)
var iconFilePath = path.join( const mkdirp = require('mkdirp')
const iconFilePath = path.join(
os.homedir(), os.homedir(),
'.local', '.local',
'share', 'share',
'icons', 'icons',
'webtorrent-desktop.png' 'webtorrent-desktop.png'
) )
fs.mkdirp(path.dirname(iconFilePath)) mkdirp(path.dirname(iconFilePath), (err) => {
fs.writeFile(iconFilePath, iconFile, function (err) {
if (err) return log.error(err.message) if (err) return log.error(err.message)
fs.writeFile(iconFilePath, iconFile, (err) => {
if (err) log.error(err.message)
})
}) })
} }
} }
function uninstallLinux () { function uninstallLinux () {
var os = require('os') const os = require('os')
var path = require('path') const path = require('path')
var fs = require('fs-extra') const rimraf = require('rimraf')
var desktopFilePath = path.join( const desktopFilePath = path.join(
os.homedir(), os.homedir(),
'.local', '.local',
'share', 'share',
'applications', 'applications',
'webtorrent-desktop.desktop' 'webtorrent-desktop.desktop'
) )
fs.removeSync(desktopFilePath) rimraf(desktopFilePath)
var iconFilePath = path.join( const iconFilePath = path.join(
os.homedir(), os.homedir(),
'.local', '.local',
'share', 'share',
'icons', 'icons',
'webtorrent-desktop.png' 'webtorrent-desktop.png'
) )
fs.removeSync(iconFilePath) rimraf(iconFilePath)
} }

View File

@@ -1,26 +1,27 @@
console.time('init') console.time('init')
var electron = require('electron') const electron = require('electron')
const app = electron.app
var app = electron.app const parallel = require('run-parallel')
var ipcMain = electron.ipcMain
var announcement = require('./announcement') const config = require('../config')
var config = require('../config') const crashReporter = require('../crash-reporter')
var crashReporter = require('../crash-reporter') const ipc = require('./ipc')
var dialog = require('./dialog') const log = require('./log')
var dock = require('./dock') const menu = require('./menu')
var ipc = require('./ipc') const State = require('../renderer/lib/state')
var log = require('./log') const windows = require('./windows')
var menu = require('./menu') const Plugins = require('./plugins')
var squirrelWin32 = require('./squirrel-win32') const plugins = new Plugins()
var tray = require('./tray')
var updater = require('./updater')
var userTasks = require('./user-tasks')
var windows = require('./windows')
var shouldQuit = false let shouldQuit = false
var argv = sliceArgv(process.argv) let argv = sliceArgv(process.argv)
// Start the app without showing the main window when auto launching on login
// (On Windows and Linux, we get a flag. On MacOS, we get special API.)
const hidden = argv.includes('--hidden') ||
(process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAsHidden)
if (config.IS_PRODUCTION) { if (config.IS_PRODUCTION) {
// When Electron is running in production mode (packaged app), then run React // When Electron is running in production mode (packaged app), then run React
@@ -29,13 +30,16 @@ if (config.IS_PRODUCTION) {
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
const squirrelWin32 = require('./squirrel-win32')
shouldQuit = squirrelWin32.handleEvent(argv[0]) shouldQuit = squirrelWin32.handleEvent(argv[0])
argv = argv.filter((arg) => !arg.includes('--squirrel')) argv = argv.filter((arg) => !arg.includes('--squirrel'))
} }
if (!shouldQuit) { if (!shouldQuit && !config.IS_PORTABLE) {
// Prevent multiple instances of app from running at same time. New instances // Prevent multiple instances of app from running at same time. New instances
// signal this instance and quit. // signal this instance and quit. Note: This feature creates a lock file in
// %APPDATA%\Roaming\WebTorrent so we do not do it for the Portable App since
// we want to be "silent" as well as "portable".
shouldQuit = app.makeSingleInstance(onAppOpen) shouldQuit = app.makeSingleInstance(onAppOpen)
if (shouldQuit) { if (shouldQuit) {
app.quit() app.quit()
@@ -48,29 +52,39 @@ if (!shouldQuit) {
function init () { function init () {
if (config.IS_PORTABLE) { if (config.IS_PORTABLE) {
const path = require('path')
// Put all user data into the "Portable Settings" folder
app.setPath('userData', config.CONFIG_PATH) app.setPath('userData', config.CONFIG_PATH)
// Put Electron crash files, etc. into the "Portable Settings\Temp" folder
app.setPath('temp', path.join(config.CONFIG_PATH, 'Temp'))
} }
var isReady = false // app ready, windows can be created const ipcMain = electron.ipcMain
let isReady = false // app ready, windows can be created
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
// Open handlers must be added as early as possible parallel({
app.on('open-file', onOpen) appReady: (cb) => app.on('ready', () => cb(null)),
app.on('open-url', onOpen) state: (cb) => State.load(cb)
}, onReady)
ipc.init() function onReady (err, results) {
if (err) throw err
app.once('will-finish-launching', function () {
crashReporter.init()
})
app.on('ready', function () {
isReady = true isReady = true
const state = results.state
windows.main.init() // init new plugins then notify user
windows.webtorrent.init() plugins.subscribe(() => {
menu.init() // update menu and windows
// passing thru new plugin decorators
initApp(state)
})
plugins.init(state)
initApp(state)
// To keep app startup fast, some code is delayed. // To keep app startup fast, some code is delayed.
setTimeout(delayedInit, config.DELAYED_INIT) setTimeout(delayedInit, config.DELAYED_INIT)
@@ -78,9 +92,38 @@ function init () {
// Report uncaught exceptions // Report uncaught exceptions
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
console.error(err) console.error(err)
var errJSON = {message: err.message, stack: err.stack} const error = {message: err.message, stack: err.stack}
windows.main.dispatch('uncaughtError', 'main', errJSON) windows.main.dispatch('uncaughtError', 'main', error)
}) })
}
function initApp (state) {
// decorate app
plugins.onApp(app)
// init decorate menu
menu.init((tpl) => plugins.decorateMenu(tpl))
// init and decorate window
windows.main.init(
state,
{hidden: hidden},
(options) => plugins.decorateWindow(options)
)
windows.webtorrent.init((options) => plugins.decorateWindow(options))
plugins.onWindow([
windows.main.win,
windows.webtorrent.win
])
}
app.on('open-file', onOpen)
app.on('open-url', onOpen)
ipc.init()
app.once('will-finish-launching', function () {
crashReporter.init()
}) })
app.once('ipcReady', function () { app.once('ipcReady', function () {
@@ -94,12 +137,12 @@ function init () {
app.isQuitting = true app.isQuitting = true
e.preventDefault() e.preventDefault()
windows.main.dispatch('saveState') // try to save state on exit windows.main.dispatch('stateSaveImmediate') // try to save state on exit
ipcMain.once('savedState', () => app.quit()) ipcMain.once('stateSaved', () => app.quit())
setTimeout(() => { setTimeout(() => {
console.error('Saving state took too long. Quitting.') console.error('Saving state took too long. Quitting.')
app.quit() app.quit()
}, 2000) // quit after 2 secs, at most }, 4000) // quit after 4 secs, at most
}) })
app.on('activate', function () { app.on('activate', function () {
@@ -108,11 +151,25 @@ function init () {
} }
function delayedInit () { function delayedInit () {
if (app.isQuitting) return
const announcement = require('./announcement')
const dock = require('./dock')
const updater = require('./updater')
announcement.init() announcement.init()
dock.init() dock.init()
tray.init()
updater.init() updater.init()
userTasks.init()
if (process.platform === 'win32') {
const userTasks = require('./user-tasks')
userTasks.init()
}
if (process.platform !== 'darwin') {
const tray = require('./tray')
tray.init()
}
} }
function onOpen (e, torrentId) { function onOpen (e, torrentId) {
@@ -143,22 +200,38 @@ function onAppOpen (newArgv) {
} }
} }
// Remove leading args.
// Production: 1 arg, eg: /Applications/WebTorrent.app/Contents/MacOS/WebTorrent
// Development: 2 args, eg: electron .
// Test: 4 args, eg: electron -r .../mocks.js .
function sliceArgv (argv) { function sliceArgv (argv) {
return argv.slice(config.IS_PRODUCTION ? 1 : 2) return argv.slice(config.IS_PRODUCTION ? 1
: config.IS_TEST ? 4
: 2)
} }
function processArgv (argv) { function processArgv (argv) {
var torrentIds = [] let torrentIds = []
argv.forEach(function (arg) { argv.forEach(function (arg) {
if (arg === '-n') { if (arg === '-n' || arg === '-o' || arg === '-u') {
dialog.openSeedDirectory() // Critical path: Only load the 'dialog' package if it is needed
} else if (arg === '-o') { const dialog = require('./dialog')
dialog.openTorrentFile() if (arg === '-n') {
} else if (arg === '-u') { dialog.openSeedDirectory()
dialog.openTorrentAddress() } else if (arg === '-o') {
dialog.openTorrentFile()
} else if (arg === '-u') {
dialog.openTorrentAddress()
}
} else if (arg === '--hidden') {
// Ignore hidden argument, already being handled
} else if (arg.startsWith('-psn')) { } else if (arg.startsWith('-psn')) {
// Ignore Mac launchd "process serial number" argument // Ignore Mac launchd "process serial number" argument
// Issue: https://github.com/feross/webtorrent-desktop/issues/214 // Issue: https://github.com/webtorrent/webtorrent-desktop/issues/214
} else if (arg.startsWith('--')) {
// Ignore Spectron flags
} else if (arg === 'data:,') {
// Ignore weird Spectron argument
} else if (arg !== '.') { } else if (arg !== '.') {
// Ignore '.' argument, which gets misinterpreted as a torrent id, when a // Ignore '.' argument, which gets misinterpreted as a torrent id, when a
// development copy of WebTorrent is started while a production version is // development copy of WebTorrent is started while a production version is

View File

@@ -2,27 +2,19 @@ module.exports = {
init init
} }
var electron = require('electron') const electron = require('electron')
var app = electron.app const app = electron.app
var dialog = require('./dialog') const log = require('./log')
var dock = require('./dock') const menu = require('./menu')
var handlers = require('./handlers') const windows = require('./windows')
var log = require('./log')
var menu = require('./menu')
var powerSaveBlocker = require('./power-save-blocker')
var shell = require('./shell')
var shortcuts = require('./shortcuts')
var externalPlayer = require('./external-player')
var windows = require('./windows')
var thumbar = require('./thumbar')
// Messages from the main process, to be sent once the WebTorrent process starts // Messages from the main process, to be sent once the WebTorrent process starts
var messageQueueMainToWebTorrent = [] const messageQueueMainToWebTorrent = []
function init () { function init () {
var ipc = electron.ipcMain const ipc = electron.ipcMain
ipc.once('ipcReady', function (e) { ipc.once('ipcReady', function (e) {
app.ipcReady = true app.ipcReady = true
@@ -43,40 +35,73 @@ function init () {
* Dialog * Dialog
*/ */
ipc.on('openTorrentFile', () => dialog.openTorrentFile()) ipc.on('openTorrentFile', () => {
ipc.on('openFiles', () => dialog.openFiles()) const dialog = require('./dialog')
dialog.openTorrentFile()
})
ipc.on('openFiles', () => {
const dialog = require('./dialog')
dialog.openFiles()
})
/** /**
* Dock * Dock
*/ */
ipc.on('setBadge', (e, ...args) => dock.setBadge(...args)) ipc.on('setBadge', (e, ...args) => {
ipc.on('downloadFinished', (e, ...args) => dock.downloadFinished(...args)) const dock = require('./dock')
dock.setBadge(...args)
})
ipc.on('downloadFinished', (e, ...args) => {
const dock = require('./dock')
dock.downloadFinished(...args)
})
/** /**
* Events * Events
*/ */
ipc.on('onPlayerOpen', function () { ipc.on('onPlayerOpen', function () {
menu.setPlayerOpen(true) const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = require('./shortcuts')
const thumbar = require('./thumbar')
menu.togglePlaybackControls(true)
powerSaveBlocker.enable() powerSaveBlocker.enable()
shortcuts.enable() shortcuts.enable()
thumbar.enable() thumbar.enable()
}) })
ipc.on('onPlayerUpdate', function (e, ...args) {
const thumbar = require('./thumbar')
menu.onPlayerUpdate(...args)
thumbar.onPlayerUpdate(...args)
})
ipc.on('onPlayerClose', function () { ipc.on('onPlayerClose', function () {
menu.setPlayerOpen(false) const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = require('./shortcuts')
const thumbar = require('./thumbar')
menu.togglePlaybackControls(false)
powerSaveBlocker.disable() powerSaveBlocker.disable()
shortcuts.disable() shortcuts.disable()
thumbar.disable() thumbar.disable()
}) })
ipc.on('onPlayerPlay', function () { ipc.on('onPlayerPlay', function () {
const powerSaveBlocker = require('./power-save-blocker')
const thumbar = require('./thumbar')
powerSaveBlocker.enable() powerSaveBlocker.enable()
thumbar.onPlayerPlay() thumbar.onPlayerPlay()
}) })
ipc.on('onPlayerPause', function () { ipc.on('onPlayerPause', function () {
const powerSaveBlocker = require('./power-save-blocker')
const thumbar = require('./thumbar')
powerSaveBlocker.disable() powerSaveBlocker.disable()
thumbar.onPlayerPause() thumbar.onPlayerPause()
}) })
@@ -85,23 +110,46 @@ function init () {
* Shell * Shell
*/ */
ipc.on('openItem', (e, ...args) => shell.openItem(...args)) ipc.on('openItem', (e, ...args) => {
ipc.on('showItemInFolder', (e, ...args) => shell.showItemInFolder(...args)) const shell = require('./shell')
ipc.on('moveItemToTrash', (e, ...args) => shell.moveItemToTrash(...args)) shell.openItem(...args)
})
ipc.on('showItemInFolder', (e, ...args) => {
const shell = require('./shell')
shell.showItemInFolder(...args)
})
ipc.on('moveItemToTrash', (e, ...args) => {
const shell = require('./shell')
shell.moveItemToTrash(...args)
})
/** /**
* File handlers * File handlers
*/ */
ipc.on('setDefaultFileHandler', (e, flag) => { ipc.on('setDefaultFileHandler', (e, flag) => {
const handlers = require('./handlers')
if (flag) handlers.install() if (flag) handlers.install()
else handlers.uninstall() else handlers.uninstall()
}) })
/**
* Auto start on login
*/
ipc.on('setStartup', (e, flag) => {
const startup = require('./startup')
if (flag) startup.install()
else startup.uninstall()
})
/** /**
* Windows: Main * Windows: Main
*/ */
var main = windows.main const main = windows.main
ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args)) ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
ipc.on('setBounds', (e, ...args) => main.setBounds(...args)) ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
@@ -116,16 +164,32 @@ function init () {
*/ */
ipc.on('checkForExternalPlayer', function (e, path) { ipc.on('checkForExternalPlayer', function (e, path) {
externalPlayer.checkInstall(path, function (isInstalled) { const externalPlayer = require('./external-player')
windows.main.send('checkForExternalPlayer', isInstalled)
externalPlayer.checkInstall(path, function (err) {
windows.main.send('checkForExternalPlayer', !err)
}) })
}) })
ipc.on('openExternalPlayer', (e, ...args) => externalPlayer.spawn(...args)) ipc.on('openExternalPlayer', (e, ...args) => {
ipc.on('quitExternalPlayer', () => externalPlayer.kill()) const externalPlayer = require('./external-player')
const thumbar = require('./thumbar')
// Capture all events menu.togglePlaybackControls(false)
var oldEmit = ipc.emit thumbar.disable()
externalPlayer.spawn(...args)
})
ipc.on('quitExternalPlayer', () => {
const externalPlayer = require('./external-player')
externalPlayer.kill()
})
/**
* Message passing
*/
const oldEmit = ipc.emit
ipc.emit = function (name, e, ...args) { ipc.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-') && !app.isQuitting) { if (name.startsWith('wt-') && !app.isQuitting) {

View File

@@ -8,10 +8,10 @@ module.exports.error = error
* where they can be viewed in Developer Tools. * where they can be viewed in Developer Tools.
*/ */
var electron = require('electron') const electron = require('electron')
var windows = require('./windows') const windows = require('./windows')
var app = electron.app const app = electron.app
function log (...args) { function log (...args) {
if (app.ipcReady) { if (app.ipcReady) {

View File

@@ -1,30 +1,34 @@
module.exports = { module.exports = {
init, init,
setPlayerOpen, togglePlaybackControls,
setWindowFocus, setWindowFocus,
setAllowNav, setAllowNav,
onPlayerUpdate,
onToggleAlwaysOnTop, onToggleAlwaysOnTop,
onToggleFullScreen onToggleFullScreen
} }
var electron = require('electron') const electron = require('electron')
var app = electron.app const app = electron.app
var config = require('../config') const config = require('../config')
var dialog = require('./dialog') const windows = require('./windows')
var shell = require('./shell')
var windows = require('./windows')
var menu let menu = null
function init () { function init (decorate) {
menu = electron.Menu.buildFromTemplate(getMenuTemplate()) let template = getMenuTemplate()
if (decorate) template = decorate(template)
menu = electron.Menu.buildFromTemplate(template)
electron.Menu.setApplicationMenu(menu) electron.Menu.setApplicationMenu(menu)
} }
function setPlayerOpen (flag) { function togglePlaybackControls (flag) {
getMenuItem('Play/Pause').enabled = flag getMenuItem('Play/Pause').enabled = flag
getMenuItem('Skip Next').enabled = flag
getMenuItem('Skip Previous').enabled = flag
getMenuItem('Increase Volume').enabled = flag getMenuItem('Increase Volume').enabled = flag
getMenuItem('Decrease Volume').enabled = flag getMenuItem('Decrease Volume').enabled = flag
getMenuItem('Step Forward').enabled = flag getMenuItem('Step Forward').enabled = flag
@@ -32,6 +36,16 @@ function setPlayerOpen (flag) {
getMenuItem('Increase Speed').enabled = flag getMenuItem('Increase Speed').enabled = flag
getMenuItem('Decrease Speed').enabled = flag getMenuItem('Decrease Speed').enabled = flag
getMenuItem('Add Subtitles File...').enabled = flag getMenuItem('Add Subtitles File...').enabled = flag
if (flag === false) {
getMenuItem('Skip Next').enabled = false
getMenuItem('Skip Previous').enabled = false
}
}
function onPlayerUpdate (hasNext, hasPrevious) {
getMenuItem('Skip Next').enabled = hasNext
getMenuItem('Skip Previous').enabled = hasPrevious
} }
function setWindowFocus (flag) { function setWindowFocus (flag) {
@@ -42,9 +56,12 @@ function setWindowFocus (flag) {
// Disallow opening more screens on top of the current one. // Disallow opening more screens on top of the current one.
function setAllowNav (flag) { function setAllowNav (flag) {
getMenuItem('Preferences').enabled = flag getMenuItem('Preferences').enabled = flag
getMenuItem('Create New Torrent...').enabled = flag if (process.platform === 'darwin') {
var item = getMenuItem('Create New Torrent from File...') getMenuItem('Create New Torrent...').enabled = flag
if (item) item.enabled = flag } else {
getMenuItem('Create New Torrent from Folder...').enabled = flag
getMenuItem('Create New Torrent from File...').enabled = flag
}
} }
function onToggleAlwaysOnTop (flag) { function onToggleAlwaysOnTop (flag) {
@@ -56,8 +73,8 @@ function onToggleFullScreen (flag) {
} }
function getMenuItem (label) { function getMenuItem (label) {
for (var i = 0; i < menu.items.length; i++) { for (let i = 0; i < menu.items.length; i++) {
var menuItem = menu.items[i].submenu.items.find(function (item) { const menuItem = menu.items[i].submenu.items.find(function (item) {
return item.label === label return item.label === label
}) })
if (menuItem) return menuItem if (menuItem) return menuItem
@@ -65,7 +82,7 @@ function getMenuItem (label) {
} }
function getMenuTemplate () { function getMenuTemplate () {
var template = [ const template = [
{ {
label: 'File', label: 'File',
submenu: [ submenu: [
@@ -74,17 +91,26 @@ function getMenuTemplate () {
? 'Create New Torrent...' ? 'Create New Torrent...'
: 'Create New Torrent from Folder...', : 'Create New Torrent from Folder...',
accelerator: 'CmdOrCtrl+N', accelerator: 'CmdOrCtrl+N',
click: () => dialog.openSeedDirectory() click: () => {
const dialog = require('./dialog')
dialog.openSeedDirectory()
}
}, },
{ {
label: 'Open Torrent File...', label: 'Open Torrent File...',
accelerator: 'CmdOrCtrl+O', accelerator: 'CmdOrCtrl+O',
click: () => dialog.openTorrentFile() click: () => {
const dialog = require('./dialog')
dialog.openTorrentFile()
}
}, },
{ {
label: 'Open Torrent Address...', label: 'Open Torrent Address...',
accelerator: 'CmdOrCtrl+U', accelerator: 'CmdOrCtrl+U',
click: () => dialog.openTorrentAddress() click: () => {
const dialog = require('./dialog')
dialog.openTorrentAddress()
}
}, },
{ {
type: 'separator' type: 'separator'
@@ -184,6 +210,21 @@ function getMenuTemplate () {
{ {
type: 'separator' type: 'separator'
}, },
{
label: 'Skip Next',
accelerator: 'N',
click: () => windows.main.dispatch('nextTrack'),
enabled: false
},
{
label: 'Skip Previous',
accelerator: 'P',
click: () => windows.main.dispatch('previousTrack'),
enabled: false
},
{
type: 'separator'
},
{ {
label: 'Increase Volume', label: 'Increase Volume',
accelerator: 'CmdOrCtrl+Up', accelerator: 'CmdOrCtrl+Up',
@@ -240,31 +281,53 @@ function getMenuTemplate () {
} }
] ]
}, },
{
label: 'Transfers',
submenu: [
{
label: 'Pause All',
click: () => windows.main.dispatch('pauseAllTorrents')
},
{
label: 'Resume All',
click: () => windows.main.dispatch('resumeAllTorrents')
}
]
},
{ {
label: 'Help', label: 'Help',
role: 'help', role: 'help',
submenu: [ submenu: [
{ {
label: 'Learn more about ' + config.APP_NAME, label: 'Learn more about ' + config.APP_NAME,
click: () => shell.openExternal(config.HOME_PAGE_URL) click: () => {
const shell = require('./shell')
shell.openExternal(config.HOME_PAGE_URL)
}
}, },
{ {
label: 'Contribute on GitHub', label: 'Contribute on GitHub',
click: () => shell.openExternal(config.GITHUB_URL) click: () => {
const shell = require('./shell')
shell.openExternal(config.GITHUB_URL)
}
}, },
{ {
type: 'separator' type: 'separator'
}, },
{ {
label: 'Report an Issue...', label: 'Report an Issue...',
click: () => shell.openExternal(config.GITHUB_URL_ISSUES) click: () => {
const shell = require('./shell')
shell.openExternal(config.GITHUB_URL_ISSUES)
}
} }
] ]
} }
] ]
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// Add WebTorrent app menu (Mac) // WebTorrent menu (Mac)
template.unshift({ template.unshift({
label: config.APP_NAME, label: config.APP_NAME,
submenu: [ submenu: [
@@ -307,8 +370,26 @@ function getMenuTemplate () {
] ]
}) })
// Add Window menu (Mac) // Edit menu (Mac)
template.splice(5, 0, { template[2].submenu.push(
{
type: 'separator'
},
{
label: 'Speech',
submenu: [
{
role: 'startspeaking'
},
{
role: 'stopspeaking'
}
]
}
)
// Window menu (Mac)
template.splice(6, 0, {
role: 'window', role: 'window',
submenu: [ submenu: [
{ {
@@ -330,7 +411,10 @@ function getMenuTemplate () {
// File menu (Windows, Linux) // File menu (Windows, Linux)
template[0].submenu.unshift({ template[0].submenu.unshift({
label: 'Create New Torrent from File...', label: 'Create New Torrent from File...',
click: () => dialog.openSeedFile() click: () => {
const dialog = require('./dialog')
dialog.openSeedFile()
}
}) })
// Edit menu (Windows, Linux) // Edit menu (Windows, Linux)
@@ -345,7 +429,7 @@ function getMenuTemplate () {
}) })
// Help menu (Windows, Linux) // Help menu (Windows, Linux)
template[4].submenu.push( template[5].submenu.push(
{ {
type: 'separator' type: 'separator'
}, },

424
src/main/plugins.js Normal file
View File

@@ -0,0 +1,424 @@
const {exec} = require('child_process')
const {resolve, basename} = require('path')
const {writeFileSync} = require('fs')
const State = require('../renderer/lib/state')
const notifier = require('node-notifier')
const {app} = require('electron')
const {sync: mkdirpSync} = require('mkdirp')
const ms = require('ms')
const shellEnv = require('shell-env')
const crypto = require('crypto')
const config = require('../config')
module.exports = class Plugins {
constructor () {
// modules path
this.path = resolve(config.getConfigPath(), 'plugins')
log('Path: ', this.path)
this.availableExtensions = new Set([
'onApp', 'onWindow', 'decorateMenu', 'decorateWindow', 'decorateConfig'
])
this.forceUpdate = false
this.updating = false
this.watchers = []
}
init (state) {
this.state = state
// initialize state
this.state.saved = Object.assign(this.state.saved || {})
// init plugin directories if not present
mkdirpSync(this.path)
// caches
this.plugins = config.getPlugins()
this.paths = this.getPaths(this.plugins)
this.id = this.getId(this.plugins)
this.modules = this.requirePlugins()
// we listen on configuration updates to trigger
// plugin installation
config.subscribe(() => {
const plugins = config.getPlugins()
if (plugins !== this.plugins) {
const id = this.getId(plugins)
if (this.id !== id) {
this.alert('Installing plugins...')
log('UPDATING...')
this.id = id
this.plugins = plugins
this.paths = this.getPaths(this.plugins)
this.updatePlugins()
}
}
})
// schedule the initial plugins update
// a bit after the user launches the app
// to prevent slowness
if (this.needsUpdate()) {
setTimeout(() => {
this.updatePlugins()
}, 5000)
log('Installation scheduled')
}
// update plugins every 5 hours
setInterval(() => {
this.updatePlugins()
}, ms('5h'))
}
on (action, params) {
log(`ON ${action}:`, params)
this.modules.forEach(plugin => {
const actionName = this.capitalizeFirstLetter(action)
const methodName = `on${actionName}`
if (typeof plugin[methodName] === 'function') {
plugin[methodName](params)
}
})
}
capitalizeFirstLetter (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
didPluginsChange () {
return this.state.saved.installedPlugins !== this.id
}
hasPlugins () {
return !this.isEmptyObject(this.plugins)
}
isFirstInstall () {
return (!this.state.saved.installedPlugins && this.hasPlugins())
}
needsUpdate () {
return (this.didPluginsChange() || this.isFirstInstall())
}
getId (plugins) {
const hash = crypto.createHash('sha256')
hash.update(JSON.stringify(plugins))
return hash.digest('hex')
}
updatePlugins (forceUpdate = false) {
this.forceUpdate = forceUpdate
if (this.updating) {
// TODO
// return notify('Plugin update in progress')
}
this.updating = true
this.syncPackageJSON()
this.installPackages((err) => this.loadPlugins(err))
}
loadPlugins (err, localOnly = false) {
this.updating = false
// handle errors first
if (err) {
console.error(err.stack)
if (/not a recognized/.test(err.message) || /command not found/.test(err.message)) {
this.alert(
'Error updating plugins: We could not find the "npm" command. Make sure it\'s in $PATH'
)
return
}
this.alert(`Error updating plugins: Check '${this.path}/npm-debug.log' for more information.`)
return
}
// update state with latest plugins
this.state.saved.plugins = this.plugins
// cache modules
this.modules = this.requirePlugins()
// clear require cache
this.clearCache()
// we're done with local plugins
if (localOnly) return
// OK, no errors
// flag successful plugin update
this.state.saved.installedPlugins = this.id
// check if package based plugins were updated
const loaded = this.modules.length
const total = this.paths.plugins.length
const pluginVersions = JSON.stringify(this.getPluginVersions())
const changed = this.state.saved.installedPluginVersions !== pluginVersions && loaded === total
this.state.saved.installedPluginVersions = pluginVersions
// notify watchers
if (this.forceUpdate || changed) {
this.watchers.forEach(fn => fn(err, {forceUpdate: this.forceUpdate}))
this.alert('Installation completed')
log('Installation completed')
}
// save state
State.save(this.state)
}
getPluginVersions () {
const paths_ = this.paths.plugins
return paths_.map(path => {
let version = null
try {
// eslint-disable-next-line import/no-dynamic-require
version = require(resolve(path, 'package.json')).version
} catch (err) { }
return [
basename(path),
version
]
})
}
clearCache () {
// trigger unload hooks
this.modules.forEach(mod => {
if (mod.onUnload) {
mod.onUnload(app)
}
})
// clear require cache
for (const entry in require.cache) {
if (entry.indexOf(this.path) === 0) {
delete require.cache[entry]
}
}
}
isEmptyObject (obj) {
return (Object.keys(obj).length === 0)
}
syncPackageJSON () {
const dependencies = this.toDependencies(this.plugins)
const pkg = {
name: 'webtorrent-plugins',
description: 'Auto-generated from WebTorrent config.',
private: true,
version: '0.0.1',
repository: 'feross/webtorrent-desktop',
license: 'MIT',
homepage: 'https://webtorrent.io',
dependencies
}
const file = resolve(this.path, 'package.json')
try {
writeFileSync(file, JSON.stringify(pkg, null, 2))
return true
} catch (err) {
this.alert(`An error occurred writing to ${file}`)
}
}
alert (message) {
notifier.notify({
title: 'WebTorrent Plugins',
// icon: config.icon, // TODO: save icon in webtorrent local folder and set config.icon
message: message
})
}
isLocalPath (string) {
// matches unix and windows local paths
return string.match(/^(\/|[a-z]:\/)/i)
}
toDependencies (plugins) {
const obj = {}
const pluginNames = Object.keys(plugins)
pluginNames.forEach(name => {
let url = plugins[name]
if (this.isLocalPath(url)) return
obj[name] = url
})
return obj
}
installPackages (fn) {
const {shell = '', npmRegistry} = config
shellEnv(shell).then(env => {
if (npmRegistry) {
env.NPM_CONFIG_REGISTRY = npmRegistry
}
/* eslint-disable camelcase */
env.npm_config_runtime = 'electron'
env.npm_config_target = process.versions.electron
env.npm_config_disturl = 'https://atom.io/download/atom-shell'
/* eslint-enable camelcase */
// Shell-specific installation commands
const installCommands = {
fish: 'npm prune; and npm install --production',
posix: 'npm prune && npm install --production'
}
// determine the shell we're running in
const whichShell = shell.match(/fish/) ? 'fish' : 'posix'
// Use the install command that is appropriate for our shell
exec(installCommands[whichShell], {
cwd: this.path
}, err => {
if (err) return fn(err)
fn(null)
})
}).catch(fn)
}
subscribe (fn) {
this.watchers.push(fn)
return () => {
this.watchers.splice(this.watchers.indexOf(fn), 1)
}
}
getPaths (plugins) {
const pluginNames = Object.keys(plugins)
return {
plugins: pluginNames.map(name => {
let url = plugins[name]
// plugin is already on a local folder
// directly load it from its current location
if (this.isLocalPath(url)) return url
// plugin will be installed with npm install from a remote url
return resolve(this.path, 'node_modules', name.split('#')[0])
})
}
}
requirePlugins () {
const {plugins} = this.paths
let installNeeded = false
const load = (path) => {
if (!path.match(/\/$/)) {
path += '/'
}
const mainPath = `${path}main.js`
try {
const Plugin = require(mainPath) // eslint-disable import/no-dynamic-require
const plugin = new Plugin()
const exposed = plugin && Object.keys(plugin).some(key => this.availableExtensions.has(key))
if (!exposed) return
// populate the name for internal errors here
plugin._name = basename(mainPath)
return plugin
} catch (err) {
log('Require plugins ERROR:', err)
this.alert(`Error loading plugin: ${mainPath}`)
// plugin not installed
// node_modules removed? did a manual plugin uninstall?
// try installing and then loading if successfull
installNeeded = true
}
}
// Plugin installation happens on the MAIN process.
// If plugins haven't finished installing, wait for them.
if (installNeeded) {
log('Plugins install needed, wait...')
setTimeout(() => {
this.requirePlugins()
}, 3000)
}
return plugins.map(load).filter(v => Boolean(v))
}
onApp (app) {
this.modules.forEach(plugin => {
if (plugin.onApp) {
plugin.onApp(app)
}
})
}
onWindow (win) {
this.modules.forEach(plugin => {
if (plugin.onWindow) {
plugin.onWindow(win)
}
})
}
// decorates the base object by calling plugin[key]
// for all the available plugins
decorateObject (base, key) {
let decorated = base
this.modules.forEach(plugin => {
if (plugin[key]) {
const res = plugin[key](decorated)
if (res && typeof res === 'object') {
decorated = res
} else {
this.alert(`Plugin error: "${plugin._name}": invalid return type for \`${key}\``)
}
}
})
return decorated
}
decorateMenu (tpl) {
return this.decorateObject(tpl, 'decorateMenu')
}
decorateWindow (options) {
return this.decorateObject(options, 'decorateWindow')
}
getDecoratedEnv (baseEnv) {
return this.decorateObject(baseEnv, 'decorateEnv')
}
getDecoratedConfig () {
const baseConfig = config.getConfig()
return this.decorateObject(baseConfig, 'decorateConfig')
}
getDecoratedBrowserOptions (defaults) {
return this.decorateObject(defaults, 'decorateBrowserOptions')
}
}
/**
* Logs passed arguments to console using a prefix.
*
*/
function log () {
const prefix = '[ PLUGINS.Main ]-->'
const args = [prefix]
for (var i = 0; i < arguments.length; ++i) {
args.push(arguments[i])
}
console.log.apply(console, args)
}

View File

@@ -3,10 +3,10 @@ module.exports = {
disable disable
} }
var electron = require('electron') const electron = require('electron')
var log = require('./log') const log = require('./log')
var blockId = 0 let blockId = 0
/** /**
* Block the system from entering low-power (sleep) mode or turning off the * Block the system from entering low-power (sleep) mode or turning off the

View File

@@ -5,8 +5,8 @@ module.exports = {
moveItemToTrash moveItemToTrash
} }
var electron = require('electron') const electron = require('electron')
var log = require('./log') const log = require('./log')
/** /**
* Open the given external protocol URL in the desktops default manner. * Open the given external protocol URL in the desktops default manner.

View File

@@ -3,8 +3,8 @@ module.exports = {
enable enable
} }
var electron = require('electron') const electron = require('electron')
var windows = require('./windows') const windows = require('./windows')
function enable () { function enable () {
// Register play/pause media key, available on some keyboards. // Register play/pause media key, available on some keyboards.
@@ -12,9 +12,19 @@ function enable () {
'MediaPlayPause', 'MediaPlayPause',
() => windows.main.dispatch('playPause') () => windows.main.dispatch('playPause')
) )
electron.globalShortcut.register(
'MediaNextTrack',
() => windows.main.dispatch('nextTrack')
)
electron.globalShortcut.register(
'MediaPreviousTrack',
() => windows.main.dispatch('previousTrack')
)
} }
function disable () { function disable () {
// Return the media key to the OS, so other apps can use it. // Return the media key to the OS, so other apps can use it.
electron.globalShortcut.unregister('MediaPlayPause') electron.globalShortcut.unregister('MediaPlayPause')
electron.globalShortcut.unregister('MediaNextTrack')
electron.globalShortcut.unregister('MediaPreviousTrack')
} }

View File

@@ -2,18 +2,18 @@ module.exports = {
handleEvent handleEvent
} }
var cp = require('child_process') const cp = require('child_process')
var electron = require('electron') const electron = require('electron')
var fs = require('fs') const fs = require('fs')
var os = require('os') const os = require('os')
var path = require('path') const path = require('path')
var app = electron.app const app = electron.app
var handlers = require('./handlers') const handlers = require('./handlers')
var EXE_NAME = path.basename(process.execPath) const EXE_NAME = path.basename(process.execPath)
var UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe') const UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe')
function handleEvent (cmd) { function handleEvent (cmd) {
if (cmd === '--squirrel-install') { if (cmd === '--squirrel-install') {
@@ -73,9 +73,9 @@ function handleEvent (cmd) {
* the output from standard out. * the output from standard out.
*/ */
function spawn (command, args, cb) { function spawn (command, args, cb) {
var stdout = '' let stdout = ''
let error = null
var child let child = null
try { try {
child = cp.spawn(command, args) child = cp.spawn(command, args)
} catch (err) { } catch (err) {
@@ -90,10 +90,10 @@ function spawn (command, args, cb) {
stdout += data stdout += data
}) })
var error = null
child.on('error', function (processError) { child.on('error', function (processError) {
error = processError error = processError
}) })
child.on('close', function (code, signal) { child.on('close', function (code, signal) {
if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}') if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}')
if (error) error.stdout = stdout if (error) error.stdout = stdout
@@ -122,12 +122,12 @@ function createShortcuts (cb) {
* command. * command.
*/ */
function updateShortcuts (cb) { function updateShortcuts (cb) {
var homeDir = os.homedir() const homeDir = os.homedir()
if (homeDir) { if (homeDir) {
var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk') const desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk')
// If the desktop shortcut was deleted by the user, then keep it deleted. // If the desktop shortcut was deleted by the user, then keep it deleted.
fs.access(desktopShortcutPath, function (err) { fs.access(desktopShortcutPath, function (err) {
var desktopShortcutExists = !err const desktopShortcutExists = !err
createShortcuts(function () { createShortcuts(function () {
if (desktopShortcutExists) { if (desktopShortcutExists) {
cb() cb()

36
src/main/startup.js Normal file
View File

@@ -0,0 +1,36 @@
module.exports = {
install,
uninstall
}
const config = require('../config')
const AutoLaunch = require('auto-launch')
const { app } = require('electron')
// On Mac, work around a bug in auto-launch where it opens a Terminal window
// See https://github.com/Teamwork/node-auto-launch/issues/28#issuecomment-222194437
const appPath = process.platform === 'darwin'
? app.getPath('exe').replace(/\.app\/Content.*/, '.app')
: undefined // Use the default
const appLauncher = new AutoLaunch({
name: config.APP_NAME,
path: appPath,
isHidden: true
})
function install () {
return appLauncher
.isEnabled()
.then(enabled => {
if (!enabled) return appLauncher.enable()
})
}
function uninstall () {
return appLauncher
.isEnabled()
.then(enabled => {
if (enabled) return appLauncher.disable()
})
}

View File

@@ -2,7 +2,8 @@ module.exports = {
disable, disable,
enable, enable,
onPlayerPause, onPlayerPause,
onPlayerPlay onPlayerPlay,
onPlayerUpdate
} }
/** /**
@@ -11,44 +12,80 @@ module.exports = {
* or activating the window. * or activating the window.
*/ */
var path = require('path') const path = require('path')
var config = require('../config') const config = require('../config')
var windows = require('./windows') const windows = require('./windows')
const PREV_ICON = path.join(config.STATIC_PATH, 'PreviousTrackThumbnailBarButton.png')
const PLAY_ICON = path.join(config.STATIC_PATH, 'PlayThumbnailBarButton.png')
const PAUSE_ICON = path.join(config.STATIC_PATH, 'PauseThumbnailBarButton.png')
const NEXT_ICON = path.join(config.STATIC_PATH, 'NextTrackThumbnailBarButton.png')
// Array indices for each button
const PREV = 0
const PLAY_PAUSE = 1
const NEXT = 2
let buttons = []
/** /**
* Show the Windows thumbnail toolbar buttons. * Show the Windows thumbnail toolbar buttons.
*/ */
function enable () { function enable () {
update(false) buttons = [
{
tooltip: 'Previous Track',
icon: PREV_ICON,
click: () => windows.main.dispatch('previousTrack')
},
{
tooltip: 'Pause',
icon: PAUSE_ICON,
click: () => windows.main.dispatch('playPause')
},
{
tooltip: 'Next Track',
icon: NEXT_ICON,
click: () => windows.main.dispatch('nextTrack')
}
]
update()
} }
/** /**
* Hide the Windows thumbnail toolbar buttons. * Hide the Windows thumbnail toolbar buttons.
*/ */
function disable () { function disable () {
windows.main.win.setThumbarButtons([]) buttons = []
update()
} }
function onPlayerPause () { function onPlayerPause () {
update(true) if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Play'
buttons[PLAY_PAUSE].icon = PLAY_ICON
update()
} }
function onPlayerPlay () { function onPlayerPlay () {
update(false) if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Pause'
buttons[PLAY_PAUSE].icon = PAUSE_ICON
update()
} }
function update (isPaused) { function onPlayerUpdate (state) {
var icon = isPaused if (!isEnabled()) return
? 'PlayThumbnailBarButton.png' buttons[PREV].flags = [ state.hasPrevious ? 'enabled' : 'disabled' ]
: 'PauseThumbnailBarButton.png' buttons[NEXT].flags = [ state.hasNext ? 'enabled' : 'disabled' ]
update()
}
var buttons = [ function isEnabled () {
{ return buttons.length > 0
tooltip: isPaused ? 'Play' : 'Pause', }
icon: path.join(config.STATIC_PATH, icon),
click: () => windows.main.dispatch('playPause') function update () {
}
]
windows.main.win.setThumbarButtons(buttons) windows.main.win.setThumbarButtons(buttons)
} }

View File

@@ -4,14 +4,14 @@ module.exports = {
setWindowFocus setWindowFocus
} }
var electron = require('electron') const electron = require('electron')
var app = electron.app const app = electron.app
var config = require('../config') const config = require('../config')
var windows = require('./windows') const windows = require('./windows')
var tray let tray
function init () { function init () {
if (process.platform === 'linux') { if (process.platform === 'linux') {
@@ -36,8 +36,8 @@ function setWindowFocus (flag) {
} }
function initLinux () { function initLinux () {
checkLinuxTraySupport(function (supportsTray) { checkLinuxTraySupport(function (err) {
if (supportsTray) createTray() if (!err) createTray()
}) })
} }
@@ -49,16 +49,20 @@ function initWin32 () {
* Check for libappindicator1 support before creating tray icon * Check for libappindicator1 support before creating tray icon
*/ */
function checkLinuxTraySupport (cb) { function checkLinuxTraySupport (cb) {
var cp = require('child_process') const cp = require('child_process')
// Check that we're on Ubuntu (or another debian system) and that we have // 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 // 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. // always have it. If it was installed from the zip file, we might not.
cp.exec('dpkg --get-selections libappindicator1', function (err, stdout) { cp.exec('dpkg --get-selections libappindicator1', function (err, stdout) {
if (err) return cb(false) if (err) return cb(err)
// Unfortunately there's no cleaner way, as far as I can tell, to check // Unfortunately there's no cleaner way, as far as I can tell, to check
// whether a debian package is installed: // whether a debian package is installed:
cb(stdout.endsWith('\tinstall\n')) if (stdout.endsWith('\tinstall\n')) {
cb(null)
} else {
cb(new Error('debian package not installed'))
}
}) })
} }
@@ -74,7 +78,7 @@ function createTray () {
} }
function updateTrayMenu () { function updateTrayMenu () {
var contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate()) const contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
tray.setContextMenu(contextMenu) tray.setContextMenu(contextMenu)
} }

View File

@@ -2,16 +2,17 @@ module.exports = {
init init
} }
var electron = require('electron') const electron = require('electron')
var get = require('simple-get') const get = require('simple-get')
var config = require('../config') const config = require('../config')
var log = require('./log') const log = require('./log')
var windows = require('./windows') const windows = require('./windows')
var AUTO_UPDATE_URL = config.AUTO_UPDATE_URL + const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
'?version=' + config.APP_VERSION + '?version=' + config.APP_VERSION +
'&platform=' + process.platform '&platform=' + process.platform +
'&sysarch=' + config.OS_SYSARCH
function init () { function init () {
if (process.platform === 'linux') { if (process.platform === 'linux') {

View File

@@ -2,9 +2,9 @@ module.exports = {
init init
} }
var electron = require('electron') const electron = require('electron')
var app = electron.app const app = electron.app
/** /**
* Add a user task menu to the app icon on right-click. (Windows) * Add a user task menu to the app icon on right-click. (Windows)

View File

@@ -1,17 +1,17 @@
var about = module.exports = { const about = module.exports = {
init, init,
win: null win: null
} }
var config = require('../../config') const config = require('../../config')
var electron = require('electron') const electron = require('electron')
function init () { function init () {
if (about.win) { if (about.win) {
return about.win.show() return about.win.show()
} }
var win = about.win = new electron.BrowserWindow({ const win = about.win = new electron.BrowserWindow({
backgroundColor: '#ECECEC', backgroundColor: '#ECECEC',
center: true, center: true,
fullscreen: false, fullscreen: false,
@@ -32,7 +32,7 @@ function init () {
// No menu on the About window // No menu on the About window
win.setMenu(null) win.setMenu(null)
win.webContents.once('did-finish-load', function () { win.once('ready-to-show', function () {
win.show() win.show()
}) })

View File

@@ -1,4 +1,4 @@
var main = module.exports = { const main = module.exports = {
dispatch, dispatch,
hide, hide,
init, init,
@@ -14,43 +14,62 @@ var main = module.exports = {
win: null win: null
} }
var electron = require('electron') const electron = require('electron')
const debounce = require('debounce')
var app = electron.app const app = electron.app
var config = require('../../config') const config = require('../../config')
var log = require('../log') const log = require('../log')
var menu = require('../menu') const menu = require('../menu')
var tray = require('../tray')
var HEADER_HEIGHT = 37 function init (state, options, decorate) {
var TORRENT_HEIGHT = 100
function init () {
if (main.win) { if (main.win) {
return main.win.show() return main.win.show()
} }
var win = main.win = new electron.BrowserWindow({
backgroundColor: '#1E1E1E', const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds)
let windowOptions = {
backgroundColor: '#282828',
backgroundThrottling: false, // do not throttle animations/timers when page is background
darkTheme: true, // Forces dark theme (GTK+3) darkTheme: true, // Forces dark theme (GTK+3)
height: initialBounds.height,
icon: getIconPath(), // Window icon (Windows, Linux) icon: getIconPath(), // Window icon (Windows, Linux)
minWidth: config.WINDOW_MIN_WIDTH,
minHeight: config.WINDOW_MIN_HEIGHT, minHeight: config.WINDOW_MIN_HEIGHT,
minWidth: config.WINDOW_MIN_WIDTH,
show: false,
title: config.APP_WINDOW_TITLE, title: config.APP_WINDOW_TITLE,
titleBarStyle: 'hidden-inset', // Hide title bar (Mac) titleBarStyle: 'hidden-inset', // Hide title bar (Mac)
useContentSize: true, // Specify web page size without OS chrome useContentSize: true, // Specify web page size without OS chrome
width: 500, width: initialBounds.width,
height: HEADER_HEIGHT + (TORRENT_HEIGHT * 6) // header height + 5 torrents x: initialBounds.x,
}) y: initialBounds.y
}
if (decorate) windowOptions = decorate(windowOptions)
const win = main.win = new electron.BrowserWindow(windowOptions)
win.loadURL(config.WINDOW_MAIN) win.loadURL(config.WINDOW_MAIN)
if (win.setSheetOffset) win.setSheetOffset(HEADER_HEIGHT) win.once('ready-to-show', () => {
if (!options.hidden) win.show()
})
if (win.setSheetOffset) {
win.setSheetOffset(config.UI_HEADER_HEIGHT)
}
win.webContents.on('dom-ready', function () { win.webContents.on('dom-ready', function () {
menu.onToggleFullScreen(main.win.isFullScreen()) menu.onToggleFullScreen(main.win.isFullScreen())
}) })
win.webContents.on('will-navigate', (e, url) => {
// Prevent drag-and-drop from navigating the Electron window, which can happen
// before our drag-and-drop handlers have been initialized.
e.preventDefault()
})
win.on('blur', onWindowBlur) win.on('blur', onWindowBlur)
win.on('focus', onWindowFocus) win.on('focus', onWindowFocus)
@@ -69,10 +88,23 @@ function init () {
win.setMenuBarVisibility(true) win.setMenuBarVisibility(true)
}) })
win.on('move', debounce(function (e) {
send('windowBoundsChanged', e.sender.getBounds())
}, 1000))
win.on('resize', debounce(function (e) {
send('windowBoundsChanged', e.sender.getBounds())
}, 1000))
win.on('close', function (e) { win.on('close', function (e) {
if (process.platform !== 'darwin' && !tray.hasTray()) { if (process.platform !== 'darwin') {
app.quit() const tray = require('../tray')
} else if (!app.isQuitting) { if (!tray.hasTray()) {
app.quit()
return
}
}
if (!app.isQuitting) {
e.preventDefault() e.preventDefault()
hide() hide()
} }
@@ -85,7 +117,7 @@ function dispatch (...args) {
function hide () { function hide () {
if (!main.win) return if (!main.win) return
main.win.send('dispatch', 'backToList') dispatch('backToList')
main.win.hide() main.win.hide()
} }
@@ -114,7 +146,7 @@ function setBounds (bounds, maximize) {
} }
// Maximize or minimize, if the second argument is present // Maximize or minimize, if the second argument is present
var willBeMaximized let willBeMaximized
if (maximize === true) { if (maximize === true) {
if (!main.win.isMaximized()) { if (!main.win.isMaximized()) {
log('setBounds: maximizing') log('setBounds: maximizing')
@@ -136,9 +168,9 @@ function setBounds (bounds, maximize) {
log('setBounds: setting bounds to ' + JSON.stringify(bounds)) log('setBounds: setting bounds to ' + JSON.stringify(bounds))
if (bounds.x === null && bounds.y === null) { if (bounds.x === null && bounds.y === null) {
// X and Y not specified? By default, center on current screen // X and Y not specified? By default, center on current screen
var scr = electron.screen.getDisplayMatching(main.win.getBounds()) const scr = electron.screen.getDisplayMatching(main.win.getBounds())
bounds.x = Math.round(scr.bounds.x + scr.bounds.width / 2 - bounds.width / 2) 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) bounds.y = Math.round(scr.bounds.y + (scr.bounds.height / 2) - (bounds.height / 2))
log('setBounds: centered to ' + JSON.stringify(bounds)) log('setBounds: centered to ' + JSON.stringify(bounds))
} }
// Resize the window's content area (so window border doesn't need to be taken // Resize the window's content area (so window border doesn't need to be taken
@@ -211,12 +243,20 @@ function toggleFullScreen (flag) {
function onWindowBlur () { function onWindowBlur () {
menu.setWindowFocus(false) menu.setWindowFocus(false)
tray.setWindowFocus(false)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(false)
}
} }
function onWindowFocus () { function onWindowFocus () {
menu.setWindowFocus(true) menu.setWindowFocus(true)
tray.setWindowFocus(true)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(true)
}
} }
function getIconPath () { function getIconPath () {

View File

@@ -1,4 +1,4 @@
var webtorrent = module.exports = { const webtorrent = module.exports = {
init, init,
send, send,
show, show,
@@ -6,14 +6,14 @@ var webtorrent = module.exports = {
win: null win: null
} }
var electron = require('electron') const electron = require('electron')
var config = require('../../config') const config = require('../../config')
var log = require('../log')
function init () { function init (decorate) {
var win = webtorrent.win = new electron.BrowserWindow({ let options = {
backgroundColor: '#1E1E1E', backgroundColor: '#1E1E1E',
backgroundThrottling: false, // do not throttle animations/timers when page is background
center: true, center: true,
fullscreen: false, fullscreen: false,
fullscreenable: false, fullscreenable: false,
@@ -26,7 +26,9 @@ function init () {
title: 'webtorrent-hidden-window', title: 'webtorrent-hidden-window',
useContentSize: true, useContentSize: true,
width: 150 width: 150
}) }
if (decorate) options = decorate(options)
const win = webtorrent.win = new electron.BrowserWindow(options)
win.loadURL(config.WINDOW_WEBTORRENT) win.loadURL(config.WINDOW_WEBTORRENT)
@@ -52,7 +54,6 @@ function send (...args) {
function toggleDevTools () { function toggleDevTools () {
if (!webtorrent.win) return if (!webtorrent.win) return
log('toggleDevTools')
if (webtorrent.win.webContents.isDevToolsOpened()) { if (webtorrent.win.webContents.isDevToolsOpened()) {
webtorrent.win.webContents.closeDevTools() webtorrent.win.webContents.closeDevTools()
webtorrent.win.hide() webtorrent.win.hide()

View File

@@ -2,11 +2,14 @@ const React = require('react')
const {dispatcher} = require('../lib/dispatcher') const {dispatcher} = require('../lib/dispatcher')
module.exports = class Header extends React.Component { class Header extends React.Component {
render () { render () {
var loc = this.props.state.location const loc = this.props.state.location
return ( return (
<div key='header' className='header'> <div className='header'
onMouseMove={dispatcher('mediaMouseMoved')}
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
{this.getTitle()} {this.getTitle()}
<div className='nav left float-left'> <div className='nav left float-left'>
<i <i
@@ -31,12 +34,12 @@ module.exports = class Header extends React.Component {
getTitle () { getTitle () {
if (process.platform !== 'darwin') return null if (process.platform !== 'darwin') return null
var state = this.props.state const state = this.props.state
return (<div className='title ellipsis'>{state.window.title}</div>) return (<div className='title ellipsis'>{state.window.title}</div>)
} }
getAddButton () { getAddButton () {
var state = this.props.state const state = this.props.state
if (state.location.url() !== 'home') return null if (state.location.url() !== 'home') return null
return ( return (
<i <i
@@ -48,3 +51,5 @@ module.exports = class Header extends React.Component {
) )
} }
} }
module.exports = Header

View File

@@ -0,0 +1,34 @@
const React = require('react')
const colors = require('material-ui/styles/colors')
class Heading extends React.Component {
static get propTypes () {
return {
level: React.PropTypes.number
}
}
static get defaultProps () {
return {
level: 1
}
}
render () {
const HeadingTag = 'h' + this.props.level
const style = {
color: colors.grey100,
fontSize: 20,
marginBottom: 15,
marginTop: 30
}
return (
<HeadingTag style={style}>
{this.props.children}
</HeadingTag>
)
}
}
module.exports = Heading

View File

@@ -0,0 +1,25 @@
const React = require('react')
const FlatButton = require('material-ui/FlatButton').default
const RaisedButton = require('material-ui/RaisedButton').default
module.exports = class ModalOKCancel extends React.Component {
render () {
const cancelStyle = { marginRight: 10, color: 'black' }
const {cancelText, onCancel, okText, onOK} = this.props
return (
<div className='float-right'>
<FlatButton
className='control cancel'
style={cancelStyle}
label={cancelText}
onClick={onCancel} />
<RaisedButton
className='control ok'
primary
label={okText}
onClick={onOK}
autoFocus />
</div>
)
}
}

View File

@@ -0,0 +1,41 @@
const React = require('react')
const TextField = require('material-ui/TextField').default
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch, dispatcher} = require('../lib/dispatcher')
module.exports = class OpenTorrentAddressModal extends React.Component {
render () {
return (
<div className='open-torrent-address-modal'>
<p><label>Enter torrent address or magnet link</label></p>
<div>
<TextField
id='torrent-address-field'
className='control'
ref={(c) => { this.torrentURL = c }}
fullWidth
onKeyDown={handleKeyDown.bind(this)} />
</div>
<ModalOKCancel
cancelText='CANCEL'
onCancel={dispatcher('exitModal')}
okText='OK'
onOK={handleOK.bind(this)} />
</div>
)
}
componentDidMount () {
this.torrentURL.input.focus()
}
}
function handleKeyDown (e) {
if (e.which === 13) handleOK.call(this) /* hit Enter to submit */
}
function handleOK () {
dispatch('exitModal')
dispatch('addTorrent', this.torrentURL.input.value)
}

View File

@@ -0,0 +1,86 @@
const colors = require('material-ui/styles/colors')
const electron = require('electron')
const React = require('react')
const remote = electron.remote
const RaisedButton = require('material-ui/RaisedButton').default
const TextField = require('material-ui/TextField').default
// Lets you pick a file or directory.
// Uses the system Open File dialog.
// You can't edit the text field directly.
class PathSelector extends React.Component {
static get propTypes () {
return {
className: React.PropTypes.string,
dialog: React.PropTypes.object,
displayValue: React.PropTypes.string,
id: React.PropTypes.string,
onChange: React.PropTypes.func,
title: React.PropTypes.string.isRequired,
value: React.PropTypes.string
}
}
constructor (props) {
super(props)
this.handleClick = this.handleClick.bind(this)
}
handleClick () {
const opts = Object.assign({
defaultPath: this.props.value,
properties: [ 'openFile', 'openDirectory' ]
}, this.props.dialog)
remote.dialog.showOpenDialog(
remote.getCurrentWindow(),
opts,
(filenames) => {
if (!Array.isArray(filenames)) return
this.props.onChange && this.props.onChange(filenames[0])
}
)
}
render () {
const id = this.props.title.replace(' ', '-').toLowerCase()
const wrapperStyle = {
alignItems: 'center',
display: 'flex',
width: '100%'
}
const labelStyle = {
flex: '0 auto',
marginRight: 10,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
const textareaStyle = {
color: colors.grey50
}
const textFieldStyle = {
flex: '1'
}
const text = this.props.displayValue || this.props.value
const buttonStyle = {
marginLeft: 10
}
return (
<div className={this.props.className} style={wrapperStyle}>
<div className='label' style={labelStyle}>
{this.props.title}:
</div>
<TextField className='control' disabled id={id} value={text}
inputStyle={textareaStyle} style={textFieldStyle} />
<RaisedButton className='control' label='Change' onClick={this.handleClick}
style={buttonStyle} />
</div>
)
}
}
module.exports = PathSelector

View File

@@ -1,22 +1,24 @@
const React = require('react') const React = require('react')
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch, dispatcher} = require('../lib/dispatcher') const {dispatch, dispatcher} = require('../lib/dispatcher')
module.exports = class RemoveTorrentModal extends React.Component { module.exports = class RemoveTorrentModal extends React.Component {
render () { render () {
var state = this.props.state const state = this.props.state
var message = state.modal.deleteData const message = state.modal.deleteData
? 'Are you sure you want to remove this torrent from the list and delete the data file?' ? 'Are you sure you want to remove this torrent from the list and delete the data file?'
: 'Are you sure you want to remove this torrent from the list?' : 'Are you sure you want to remove this torrent from the list?'
var buttonText = state.modal.deleteData ? 'Remove Data' : 'Remove' const buttonText = state.modal.deleteData ? 'REMOVE DATA' : 'REMOVE'
return ( return (
<div> <div>
<p><strong>{message}</strong></p> <p><strong>{message}</strong></p>
<p className='float-right'> <ModalOKCancel
<button className='button button-flat' onClick={dispatcher('exitModal')}>Cancel</button> cancelText='CANCEL'
<button className='button button-raised' onClick={handleRemove}>{buttonText}</button> onCancel={dispatcher('exitModal')}
</p> okText={buttonText}
onOK={handleRemove} />
</div> </div>
) )

View File

@@ -0,0 +1,53 @@
const React = require('react')
const RaisedButton = require('material-ui/RaisedButton').default
class ShowMore extends React.Component {
static get propTypes () {
return {
defaultExpanded: React.PropTypes.bool,
hideLabel: React.PropTypes.string,
showLabel: React.PropTypes.string
}
}
static get defaultProps () {
return {
hideLabel: 'Hide more...',
showLabel: 'Show more...'
}
}
constructor (props) {
super(props)
this.state = {
expanded: !!this.props.defaultExpanded
}
this.handleClick = this.handleClick.bind(this)
}
handleClick () {
this.setState({
expanded: !this.state.expanded
})
}
render () {
const label = this.state.expanded
? this.props.hideLabel
: this.props.showLabel
return (
<div className='show-more' style={this.props.style}>
{this.state.expanded ? this.props.children : null}
<RaisedButton
className='control'
onClick={this.handleClick}
label={label} />
</div>
)
}
}
module.exports = ShowMore

View File

@@ -0,0 +1,44 @@
const React = require('react')
const electron = require('electron')
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatcher} = require('../lib/dispatcher')
module.exports = class UnsupportedMediaModal extends React.Component {
render () {
const state = this.props.state
const err = state.modal.error
const message = (err && err.getMessage)
? err.getMessage()
: err
const onAction = state.modal.externalPlayerInstalled
? dispatcher('openExternalPlayer')
: () => this.onInstall()
const actionText = state.modal.externalPlayerInstalled
? 'PLAY IN ' + state.getExternalPlayerName().toUpperCase()
: 'INSTALL VLC'
const errorMessage = state.modal.externalPlayerNotFound
? 'Couldn\'t run external player. Please make sure it\'s installed.'
: ''
return (
<div>
<p><strong>Sorry, we can't play that file.</strong></p>
<p>{message}</p>
<ModalOKCancel
cancelText='CANCEL'
onCancel={dispatcher('backToList')}
okText={actionText}
onOK={onAction} />
<p className='error-text'>{errorMessage}</p>
</div>
)
}
onInstall () {
electron.shell.openExternal('http://www.videolan.org/vlc/')
// TODO: dcposch send a dispatch rather than modifying state directly
const state = this.props.state
state.modal.externalPlayerInstalled = true // Assume they'll install it successfully
}
}

View File

@@ -1,24 +1,30 @@
const React = require('react') const React = require('react')
const electron = require('electron') const electron = require('electron')
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch} = require('../lib/dispatcher') const {dispatch} = require('../lib/dispatcher')
module.exports = class UpdateAvailableModal extends React.Component { module.exports = class UpdateAvailableModal extends React.Component {
render () { render () {
var state = this.props.state const state = this.props.state
return ( return (
<div className='update-available-modal'> <div className='update-available-modal'>
<p><strong>A new version of WebTorrent is available: v{state.modal.version}</strong></p> <p><strong>A new version of WebTorrent is available: v{state.modal.version}</strong></p>
<p>We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so you'll have to download the new version manually.</p> <p>
<p className='float-right'> We have an auto-updater for Windows and Mac.
<button className='button button-flat' onClick={handleSkip}>Skip This Release</button> We don't have one for Linux yet, so you'll have to download the new version manually.
<button className='button button-raised' onClick={handleShow}>Show Download Page</button>
</p> </p>
<ModalOKCancel
cancelText='SKIP THIS RELEASE'
onCancel={handleSkip}
okText='SHOW DOWNLOAD PAGE'
onOK={handleShow} />
</div> </div>
) )
function handleShow () { function handleShow () {
electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases') // TODO: use the GitHub urls from config.js
electron.shell.openExternal('https://github.com/webtorrent/webtorrent-desktop/releases')
dispatch('exitModal') dispatch('exitModal')
} }

View File

@@ -1,6 +1,6 @@
const electron = require('electron') const {ipcRenderer} = require('electron')
const telemetry = require('../lib/telemetry')
const ipcRenderer = electron.ipcRenderer const Playlist = require('../lib/playlist')
// Controls local play back: the <video>/<audio> tag and VLC // Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc) // Does not control remote casting (Chromecast etc)
@@ -10,7 +10,7 @@ module.exports = class MediaController {
} }
mediaSuccess () { mediaSuccess () {
this.state.playing.result = 'success' telemetry.logPlayAttempt('success')
} }
mediaStalled () { mediaStalled () {
@@ -18,9 +18,9 @@ module.exports = class MediaController {
} }
mediaError (error) { mediaError (error) {
var state = this.state const state = this.state
if (state.location.url() === 'player') { if (state.location.url() === 'player') {
state.playing.result = 'error' telemetry.logPlayAttempt('error')
state.playing.location = 'error' state.playing.location = 'error'
ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath) ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath)
ipcRenderer.once('checkForExternalPlayer', function (e, isInstalled) { ipcRenderer.once('checkForExternalPlayer', function (e, isInstalled) {
@@ -42,14 +42,37 @@ module.exports = class MediaController {
this.state.playing.mouseStationarySince = new Date().getTime() this.state.playing.mouseStationarySince = new Date().getTime()
} }
controlsMouseEnter () {
this.state.playing.mouseInControls = true
this.state.playing.mouseStationarySince = new Date().getTime()
}
controlsMouseLeave () {
this.state.playing.mouseInControls = false
this.state.playing.mouseStationarySince = new Date().getTime()
}
openExternalPlayer () { openExternalPlayer () {
var state = this.state const state = this.state
ipcRenderer.send('openExternalPlayer', state.saved.prefs.externalPlayerPath, state.server.localURL, state.window.title)
state.playing.location = 'external' state.playing.location = 'external'
const onServerRunning = function () {
state.playing.isReady = true
telemetry.logPlayAttempt('external')
const mediaURL = Playlist.getCurrentLocalURL(state)
ipcRenderer.send('openExternalPlayer',
state.saved.prefs.externalPlayerPath,
mediaURL,
state.window.title)
}
if (state.server != null) onServerRunning()
else ipcRenderer.once('wt-server-running', onServerRunning)
} }
externalPlayerNotFound () { externalPlayerNotFound () {
var modal = this.state.modal const modal = this.state.modal
if (modal && modal.id === 'unsupported-media-modal') { if (modal && modal.id === 'unsupported-media-modal') {
modal.externalPlayerNotFound = true modal.externalPlayerNotFound = true
} }

View File

@@ -4,10 +4,11 @@ const path = require('path')
const Cast = require('../lib/cast') const Cast = require('../lib/cast')
const {dispatch} = require('../lib/dispatcher') const {dispatch} = require('../lib/dispatcher')
const telemetry = require('../lib/telemetry') const telemetry = require('../lib/telemetry')
const errors = require('../lib/errors') const {UnplayableFileError, UnplayableTorrentError} = require('../lib/errors')
const sound = require('../lib/sound') const sound = require('../lib/sound')
const TorrentPlayer = require('../lib/torrent-player') const TorrentPlayer = require('../lib/torrent-player')
const TorrentSummary = require('../lib/torrent-summary') const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const State = require('../lib/state') const State = require('../lib/state')
const ipcRenderer = electron.ipcRenderer const ipcRenderer = electron.ipcRenderer
@@ -24,24 +25,45 @@ module.exports = class PlaybackController {
// Play a file in a torrent. // Play a file in a torrent.
// * Start torrenting, if necessary // * Start torrenting, if necessary
// * Stream, if not already fully downloaded // * Stream, if not already fully downloaded
// * If no file index is provided, pick the default file to play // * If no file index is provided, restore the most recently viewed file or autoplay the first
playFile (infoHash, index /* optional */) { playFile (infoHash, index /* optional */) {
this.state.location.go({ this.pauseActiveTorrents(infoHash)
url: 'player',
setup: (cb) => { const state = this.state
this.play() if (state.location.url() === 'player') {
this.openPlayer(infoHash, index, cb) this.updatePlayer(infoHash, index, false, (err) => {
}, if (err) dispatch('error', err)
destroy: () => this.closePlayer() else this.play()
}, (err) => { })
if (err) dispatch('error', err) } else {
}) let initialized = false
state.location.go({
url: 'player',
setup: (cb) => {
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
if (index === undefined || initialized) index = torrentSummary.mostRecentFileIndex
if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable)
if (index === undefined) return cb(new UnplayableTorrentError())
initialized = true
this.openPlayer(infoHash, index, (err) => {
if (!err) this.play()
cb(err)
})
},
destroy: () => this.closePlayer()
}, (err) => {
if (err) dispatch('error', err)
})
}
} }
// Show a file in the OS, eg in Finder on a Mac // Open a file in OS default app.
openItem (infoHash, index) { openItem (infoHash, index) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
var filePath = path.join( const filePath = path.join(
torrentSummary.path, torrentSummary.path,
torrentSummary.files[index].path) torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath) ipcRenderer.send('openItem', filePath)
@@ -49,12 +71,12 @@ module.exports = class PlaybackController {
// Toggle (play or pause) the currently playing media // Toggle (play or pause) the currently playing media
playPause () { playPause () {
var state = this.state const state = this.state
if (state.location.url() !== 'player') return if (state.location.url() !== 'player') return
// force rerendering if window is hidden, // force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately // in order to bypass `raf` and play/pause media immediately
var mediaTag = document.querySelector('video,audio') const mediaTag = document.querySelector('video,audio')
if (!state.window.isVisible && mediaTag) { if (!state.window.isVisible && mediaTag) {
if (state.playing.isPaused) mediaTag.play() if (state.playing.isPaused) mediaTag.play()
else mediaTag.pause() else mediaTag.pause()
@@ -64,9 +86,44 @@ module.exports = class PlaybackController {
else this.pause() else this.pause()
} }
pauseActiveTorrents (infoHash) {
// Playback Priority: pause all active torrents if needed.
if (!this.state.saved.prefs.highestPlaybackPriority) return
// Do not pause active torrents if playing a fully downloaded torrent.
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'seeding') return
dispatch('prioritizeTorrent', infoHash)
}
// Play next file in list (if any)
nextTrack () {
const state = this.state
if (Playlist.hasNext(state) && state.playing.location !== 'external') {
this.updatePlayer(
state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
}
}
// Play previous track in list (if any)
previousTrack () {
const state = this.state
if (Playlist.hasPrevious(state) && state.playing.location !== 'external') {
this.updatePlayer(
state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
}
}
// Play (unpause) the current media // Play (unpause) the current media
play () { play () {
var state = this.state const state = this.state
if (!state.playing.isPaused) return if (!state.playing.isPaused) return
state.playing.isPaused = false state.playing.isPaused = false
if (isCasting(state)) { if (isCasting(state)) {
@@ -77,7 +134,7 @@ module.exports = class PlaybackController {
// Pause the currently playing media // Pause the currently playing media
pause () { pause () {
var state = this.state const state = this.state
if (state.playing.isPaused) return if (state.playing.isPaused) return
state.playing.isPaused = true state.playing.isPaused = true
if (isCasting(state)) { if (isCasting(state)) {
@@ -106,25 +163,23 @@ module.exports = class PlaybackController {
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc // to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
// until -16 (fast rewind) // until -16 (fast rewind)
changePlaybackRate (direction) { changePlaybackRate (direction) {
var state = this.state const state = this.state
var rate = state.playing.playbackRate let rate = state.playing.playbackRate
if (direction > 0 && rate >= 0.25 && rate < 2) { if (direction > 0 && rate < 2) {
rate += 0.25 rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) { } else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25 rate -= 0.25
} else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */ } else if (direction > 0 && rate >= 1 && rate < 16) {
rate = -1
} else if (direction > 0 && rate === -1) {
rate = 0.25
} else if ((direction > 0 && rate >= 1 && rate < 16) || (direction < 0 && rate > -16 && rate <= -1)) {
rate *= 2 rate *= 2
} else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) { } else if (direction < 0 && rate > 1 && rate <= 16) {
rate /= 2 rate /= 2
} }
state.playing.playbackRate = rate state.playing.playbackRate = rate
if (isCasting(state) && !Cast.setRate(rate)) { if (isCasting(state) && !Cast.setRate(rate)) {
state.playing.playbackRate = 1 state.playing.playbackRate = 1
} }
// Wait a bit before we hide the controls and header again
state.playing.mouseStationarySince = new Date().getTime()
} }
// Change the volume, in range [0, 1], by some amount // Change the volume, in range [0, 1], by some amount
@@ -139,7 +194,7 @@ module.exports = class PlaybackController {
// 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))
var state = this.state const state = this.state
if (isCasting(state)) { if (isCasting(state)) {
Cast.setVolume(volume) Cast.setVolume(volume)
} else { } else {
@@ -153,12 +208,8 @@ module.exports = class PlaybackController {
// * The video is paused // * The video is paused
// * The video is playing remotely on Chromecast or Airplay // * The video is playing remotely on Chromecast or Airplay
showOrHidePlayerControls () { showOrHidePlayerControls () {
var state = this.state const state = this.state
var hideControls = state.location.url() === 'player' && const hideControls = state.shouldHidePlayerControls()
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local'
if (hideControls !== state.playing.hideControls) { if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls state.playing.hideControls = hideControls
@@ -169,60 +220,81 @@ module.exports = class PlaybackController {
// Opens the video player to a specific torrent // Opens the video player to a specific torrent
openPlayer (infoHash, index, cb) { openPlayer (infoHash, index, cb) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
// automatically choose which file in the torrent to play, if necessary state.playing.infoHash = torrentSummary.infoHash
if (index === undefined) index = torrentSummary.defaultPlayFileIndex state.playing.isReady = false
if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files)
if (index === undefined) return cb(new errors.UnplayableError())
// update UI to show pending playback // update UI to show pending playback
if (torrentSummary.progress !== 1) sound.play('PLAY') sound.play('PLAY')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'requested'
this.update()
var timeout = setTimeout(() => { this.startServer(torrentSummary)
telemetry.logPlayAttempt('timeout') ipcRenderer.send('onPlayerOpen')
// TODO: remove torrentSummary.playStatus this.updatePlayer(infoHash, index, true, cb)
torrentSummary.playStatus = 'timeout' /* no seeders available? */ }
sound.play('ERROR')
cb(new Error('Playback timed out. Try again.')) // Starts WebTorrent server for media streaming
this.update() startServer (torrentSummary) {
}, 10000) /* give it a few seconds */ const state = this.state
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary.torrentKey) dispatch('startTorrentingSummary', torrentSummary.torrentKey)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)) () => onTorrentReady())
} else { } else {
this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb) onTorrentReady()
}
function onTorrentReady () {
ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
ipcRenderer.once('wt-server-running', () => { state.playing.isReady = true })
} }
} }
openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) { // Called each time the current file changes
var fileSummary = torrentSummary.files[index] updatePlayer (infoHash, index, resume, cb) {
const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
const fileSummary = torrentSummary.files[index]
if (!TorrentPlayer.isPlayable(fileSummary)) {
torrentSummary.mostRecentFileIndex = undefined
return cb(new UnplayableFileError())
}
torrentSummary.mostRecentFileIndex = index
// update state // update state
var state = this.state state.playing.infoHash = infoHash
state.playing.infoHash = torrentSummary.infoHash
state.playing.fileIndex = index state.playing.fileIndex = index
state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video' state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video'
: TorrentPlayer.isAudio(fileSummary) ? 'audio' : TorrentPlayer.isAudio(fileSummary) ? 'audio'
: 'other' : 'other'
// pick up where we left off // pick up where we left off
if (fileSummary.currentTime) { let jumpToTime = 0
var fraction = fileSummary.currentTime / fileSummary.duration if (resume && fileSummary.currentTime) {
var secondsLeft = fileSummary.duration - fileSummary.currentTime const fraction = fileSummary.currentTime / fileSummary.duration
const secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) { if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime jumpToTime = fileSummary.currentTime
} }
} }
state.playing.jumpToTime = jumpToTime
// if it's audio, parse out the metadata (artist, title, etc) // if it's audio, parse out the metadata (artist, title, etc)
if (state.playing.type === 'audio' && !fileSummary.audioInfo) { if (torrentSummary.status === 'paused') {
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, getAudioMetadata)
} else {
getAudioMetadata()
}
function getAudioMetadata () {
if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
}
} }
// if it's video, check for subtitles files that are done downloading // if it's video, check for subtitles files that are done downloading
@@ -233,41 +305,28 @@ module.exports = class PlaybackController {
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true) dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
} }
ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index) state.window.title = fileSummary.name
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => {
clearTimeout(timeout)
// if we timed out (user clicked play a long time ago), don't autoplay // play in VLC if set as default player (Preferences / Playback / Play in VLC)
var timedOut = torrentSummary.playStatus === 'timeout' if (this.state.saved.prefs.openExternalPlayer) {
delete torrentSummary.playStatus dispatch('openExternalPlayer')
if (timedOut) {
ipcRenderer.send('wt-stop-server')
return this.update()
}
state.window.title = torrentSummary.files[state.playing.fileIndex].name
// play in VLC if set as default player (Preferences / Playback / Play in VLC)
if (this.state.saved.prefs.openExternalPlayer) {
dispatch('openExternalPlayer')
this.update()
cb()
return
}
// otherwise, play the video
this.update() this.update()
ipcRenderer.send('onPlayerOpen')
cb() cb()
}) return
}
// otherwise, play the video
this.update()
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
cb()
} }
closePlayer () { closePlayer () {
console.log('closePlayer') console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC // Quit any external players, like Chromecast/Airplay/etc or VLC
var state = this.state const state = this.state
if (isCasting(state)) { if (isCasting(state)) {
Cast.stop() Cast.stop()
} }
@@ -278,12 +337,7 @@ module.exports = class PlaybackController {
// Save volume (this session only, not in state.saved) // Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume state.previousVolume = state.playing.volume
// Telemetry: track what happens after the user clicks play if (!state.playing.isReady) telemetry.logPlayAttempt('abandoned') // user gave up waiting
var result = state.playing.result // 'success' or 'error'
if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed
else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc
else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen // Reset the window contents back to the home screen
state.playing = State.getDefaultPlayState() state.playing = State.getDefaultPlayState()
@@ -300,6 +354,11 @@ module.exports = class PlaybackController {
ipcRenderer.send('onPlayerClose') ipcRenderer.send('onPlayerClose')
// Playback Priority: resume previously paused downloads.
if (this.state.saved.prefs.highestPlaybackPriority) {
dispatch('resumePausedTorrents')
}
this.update() this.update()
} }
} }

View File

@@ -1,4 +1,3 @@
const State = require('../lib/state')
const {dispatch} = require('../lib/dispatcher') const {dispatch} = require('../lib/dispatcher')
const ipcRenderer = require('electron').ipcRenderer const ipcRenderer = require('electron').ipcRenderer
@@ -11,13 +10,15 @@ module.exports = class PrefsController {
// Goes to the Preferences screen // Goes to the Preferences screen
show () { show () {
var state = this.state const state = this.state
state.location.go({ state.location.go({
url: 'preferences', url: 'preferences',
setup: function (cb) { setup: function (cb) {
// initialize preferences // initialize preferences
state.window.title = 'Preferences' state.window.title = 'Preferences'
state.unsaved = Object.assign(state.unsaved || {}, {prefs: state.saved.prefs || {}}) state.unsaved = Object.assign(state.unsaved || {}, {
prefs: Object.assign({}, state.saved.prefs)
})
ipcRenderer.send('setAllowNav', false) ipcRenderer.send('setAllowNav', false)
cb() cb()
}, },
@@ -32,25 +33,29 @@ module.exports = class PrefsController {
// For example: updatePreferences('foo.bar', 'baz') // For example: updatePreferences('foo.bar', 'baz')
// Call save() to save to config.json // Call save() to save to config.json
update (property, value) { update (property, value) {
var path = property.split('.') const path = property.split('.')
var key = this.state.unsaved.prefs let obj = this.state.unsaved.prefs
for (var i = 0; i < path.length - 1; i++) { let i
if (typeof key[path[i]] === 'undefined') { for (i = 0; i < path.length - 1; i++) {
key[path[i]] = {} if (typeof obj[path[i]] === 'undefined') {
obj[path[i]] = {}
} }
key = key[path[i]] obj = obj[path[i]]
} }
key[path[i]] = value obj[path[i]] = value
} }
// All unsaved prefs take effect atomically, and are saved to config.json // All unsaved prefs take effect atomically, and are saved to config.json
save () { save () {
var state = this.state const state = this.state
if (state.unsaved.prefs.isFileHandler !== state.saved.prefs.isFileHandler) { if (state.unsaved.prefs.isFileHandler !== state.saved.prefs.isFileHandler) {
ipcRenderer.send('setDefaultFileHandler', state.unsaved.prefs.isFileHandler) ipcRenderer.send('setDefaultFileHandler', state.unsaved.prefs.isFileHandler)
} }
if (state.unsaved.prefs.startup !== state.saved.prefs.startup) {
ipcRenderer.send('setStartup', state.unsaved.prefs.startup)
}
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs) state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
State.save(state) dispatch('stateSaveImmediate')
dispatch('checkDownloadPath') dispatch('checkDownloadPath')
} }
} }

View File

@@ -1,5 +1,5 @@
const electron = require('electron') const electron = require('electron')
const fs = require('fs-extra') const fs = require('fs')
const path = require('path') const path = require('path')
const parallel = require('run-parallel') const parallel = require('run-parallel')
@@ -28,36 +28,35 @@ module.exports = class SubtitlesController {
} }
toggleSubtitlesMenu () { toggleSubtitlesMenu () {
var subtitles = this.state.playing.subtitles const subtitles = this.state.playing.subtitles
subtitles.showMenu = !subtitles.showMenu subtitles.showMenu = !subtitles.showMenu
} }
addSubtitles (files, autoSelect) { addSubtitles (files, autoSelect) {
var state = this.state
// Subtitles are only supported when playing video files // Subtitles are only supported when playing video files
if (state.playing.type !== 'video') return if (this.state.playing.type !== 'video') return
if (files.length === 0) return if (files.length === 0) return
var subtitles = state.playing.subtitles const subtitles = this.state.playing.subtitles
// Read the files concurrently, then add all resulting subtitle tracks // Read the files concurrently, then add all resulting subtitle tracks
var tasks = files.map((file) => (cb) => loadSubtitle(file, cb)) const tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
parallel(tasks, function (err, tracks) { parallel(tasks, function (err, tracks) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
for (var i = 0; i < tracks.length; i++) { for (let i = 0; i < tracks.length; i++) {
// No dupes allowed // No dupes allowed
var track = tracks[i] const track = tracks[i]
var trackIndex = state.playing.subtitles.tracks let trackIndex = subtitles.tracks.findIndex((t) =>
.findIndex((t) => track.filePath === t.filePath) track.filePath === t.filePath)
// Add the track // Add the track
if (trackIndex === -1) { if (trackIndex === -1) {
trackIndex = state.playing.subtitles.tracks.push(track) - 1 trackIndex = subtitles.tracks.push(track) - 1
} }
// If we're auto-selecting a track, try to find one in the user's language // If we're auto-selecting a track, try to find one in the user's language
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) { if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
state.playing.subtitles.selectedIndex = trackIndex subtitles.selectedIndex = trackIndex
} }
} }
@@ -68,46 +67,46 @@ module.exports = class SubtitlesController {
checkForSubtitles () { checkForSubtitles () {
if (this.state.playing.type !== 'video') return if (this.state.playing.type !== 'video') return
var torrentSummary = this.state.getPlayingTorrentSummary() const torrentSummary = this.state.getPlayingTorrentSummary()
if (!torrentSummary || !torrentSummary.progress) return if (!torrentSummary || !torrentSummary.progress) return
torrentSummary.progress.files.forEach((fp, ix) => { torrentSummary.progress.files.forEach((fp, ix) => {
if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files
var file = torrentSummary.files[ix] const file = torrentSummary.files[ix]
if (!this.isSubtitle(file.name)) return if (!this.isSubtitle(file.name)) return
var filePath = path.join(torrentSummary.path, file.path) const filePath = path.join(torrentSummary.path, file.path)
this.addSubtitles([filePath], false) this.addSubtitles([filePath], false)
}) })
} }
isSubtitle (file) { isSubtitle (file) {
var name = typeof file === 'string' ? file : file.name const name = typeof file === 'string' ? file : file.name
var ext = path.extname(name).toLowerCase() const ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt' return ext === '.srt' || ext === '.vtt'
} }
} }
function loadSubtitle (file, cb) { function loadSubtitle (file, cb) {
// Lazy load to keep startup fast // Lazy load to keep startup fast
var concat = require('simple-concat') const concat = require('simple-concat')
var LanguageDetect = require('languagedetect') const LanguageDetect = require('languagedetect')
var srtToVtt = require('srt-to-vtt') const srtToVtt = require('srt-to-vtt')
// Read the .SRT or .VTT file, parse it, add subtitle track // Read the .SRT or .VTT file, parse it, add subtitle track
var filePath = file.path || file const filePath = file.path || file
var vttStream = fs.createReadStream(filePath).pipe(srtToVtt()) const vttStream = fs.createReadStream(filePath).pipe(srtToVtt())
concat(vttStream, function (err, buf) { concat(vttStream, function (err, buf) {
if (err) return dispatch('error', 'Can\'t parse subtitles file.') if (err) return dispatch('error', 'Can\'t parse subtitles file.')
// Detect what language the subtitles are in // Detect what language the subtitles are in
var vttContents = buf.toString().replace(/(.*-->.*)/g, '') const vttContents = buf.toString().replace(/(.*-->.*)/g, '')
var langDetected = (new LanguageDetect()).detect(vttContents, 2) let langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle' langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1) langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var track = { const track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'), buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected, language: langDetected,
label: langDetected, label: langDetected,
@@ -121,18 +120,18 @@ function loadSubtitle (file, cb) {
// Checks whether a language name like 'English' or 'German' matches the system // Checks whether a language name like 'English' or 'German' matches the system
// language, aka the current locale // language, aka the current locale
function isSystemLanguage (language) { function isSystemLanguage (language) {
var iso639 = require('iso-639-1') const iso639 = require('iso-639-1')
var osLangISO = window.navigator.language.split('-')[0] // eg 'en' const osLangISO = window.navigator.language.split('-')[0] // eg 'en'
var langIso = iso639.getCode(language) // eg 'de' if language is 'German' const langIso = iso639.getCode(language) // eg 'de' if language is 'German'
return langIso === osLangISO return langIso === osLangISO
} }
// Make sure we don't have two subtitle tracks with the same label // Make sure we don't have two subtitle tracks with the same label
// Labels each track by language, eg 'German', 'English', 'English 2', ... // Labels each track by language, eg 'German', 'English', 'English 2', ...
function relabelSubtitles (subtitles) { function relabelSubtitles (subtitles) {
var counts = {} const counts = {}
subtitles.tracks.forEach(function (track) { subtitles.tracks.forEach(function (track) {
var lang = track.language const lang = track.language
counts[lang] = (counts[lang] || 0) + 1 counts[lang] = (counts[lang] || 0) + 1
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang
}) })

View File

@@ -2,7 +2,6 @@ const path = require('path')
const ipcRenderer = require('electron').ipcRenderer const ipcRenderer = require('electron').ipcRenderer
const TorrentSummary = require('../lib/torrent-summary') const TorrentSummary = require('../lib/torrent-summary')
const TorrentPlayer = require('../lib/torrent-player')
const sound = require('../lib/sound') const sound = require('../lib/sound')
const {dispatch} = require('../lib/dispatcher') const {dispatch} = require('../lib/dispatcher')
@@ -12,12 +11,12 @@ module.exports = class TorrentController {
} }
torrentInfoHash (torrentKey, infoHash) { torrentInfoHash (torrentKey, infoHash) {
var torrentSummary = this.getTorrentSummary(torrentKey) let torrentSummary = this.getTorrentSummary(torrentKey)
console.log('got infohash for %s torrent %s', console.log('got infohash for %s torrent %s',
torrentSummary ? 'existing' : 'new', torrentKey) torrentSummary ? 'existing' : 'new', torrentKey)
if (!torrentSummary) { if (!torrentSummary) {
var torrents = this.state.saved.torrents const torrents = this.state.saved.torrents
// Check if an existing (non-active) torrent has the same info hash // Check if an existing (non-active) torrent has the same info hash
if (torrents.find((t) => t.infoHash === infoHash)) { if (torrents.find((t) => t.infoHash === infoHash)) {
@@ -49,7 +48,7 @@ module.exports = class TorrentController {
} }
dispatch('error', message) dispatch('error', message)
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
if (torrentSummary) { if (torrentSummary) {
console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message) console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message)
torrentSummary.status = 'paused' torrentSummary.status = 'paused'
@@ -59,21 +58,20 @@ module.exports = class TorrentController {
torrentMetadata (torrentKey, torrentInfo) { torrentMetadata (torrentKey, torrentInfo) {
// Summarize torrent // Summarize torrent
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.status = 'downloading' torrentSummary.status = 'downloading'
torrentSummary.name = torrentSummary.displayName || torrentInfo.name torrentSummary.name = torrentSummary.displayName || torrentInfo.name
torrentSummary.path = torrentInfo.path torrentSummary.path = torrentInfo.path
torrentSummary.magnetURI = torrentInfo.magnetURI torrentSummary.magnetURI = torrentInfo.magnetURI
// TODO: make torrentInfo immutable, save separately as torrentSummary.info // TODO: make torrentInfo immutable, save separately as torrentSummary.info
// For now, check whether torrentSummary.files has already been set: // For now, check whether torrentSummary.files has already been set:
var hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
if (!hasDetailedFileInfo) { if (!hasDetailedFileInfo) {
torrentSummary.files = torrentInfo.files torrentSummary.files = torrentInfo.files
} }
if (!torrentSummary.selections) { if (!torrentSummary.selections) {
torrentSummary.selections = torrentSummary.files.map((x) => true) torrentSummary.selections = torrentSummary.files.map((x) => true)
} }
torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files)
dispatch('update') dispatch('update')
// Save the .torrent file, if it hasn't been saved already // Save the .torrent file, if it hasn't been saved already
@@ -85,7 +83,7 @@ module.exports = class TorrentController {
torrentDone (torrentKey, torrentInfo) { torrentDone (torrentKey, torrentInfo) {
// Update the torrent summary // Update the torrent summary
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.status = 'seeding' torrentSummary.status = 'seeding'
// Notify the user that a torrent finished, but only if we actually DL'd at least part of it. // Notify the user that a torrent finished, but only if we actually DL'd at least part of it.
@@ -102,22 +100,18 @@ module.exports = class TorrentController {
} }
torrentProgress (progressInfo) { torrentProgress (progressInfo) {
// Overall progress across all active torrents, 0 to 1 // Overall progress across all active torrents, 0 to 1, or -1 to hide the progress bar
var progress = progressInfo.progress
var hasActiveTorrents = progressInfo.hasActiveTorrents
// Hide progress bar when client has no torrents, or progress is 100% // Hide progress bar when client has no torrents, or progress is 100%
// TODO: isn't this equivalent to: if (progress === 1) ? const progress = (!progressInfo.hasActiveTorrents || progressInfo.progress === 1)
if (!hasActiveTorrents || progress === 1) { ? -1
progress = -1 : progressInfo.progress
}
// Show progress bar under the WebTorrent taskbar icon, on OSX // Show progress bar under the WebTorrent taskbar icon, on OSX
this.state.dock.progress = progress this.state.dock.progress = progress
// Update progress for each individual torrent // Update progress for each individual torrent
progressInfo.torrents.forEach((p) => { progressInfo.torrents.forEach((p) => {
var torrentSummary = this.getTorrentSummary(p.torrentKey) const torrentSummary = this.getTorrentSummary(p.torrentKey)
if (!torrentSummary) { if (!torrentSummary) {
console.log('warning: got progress for missing torrent %s', p.torrentKey) console.log('warning: got progress for missing torrent %s', p.torrentKey)
return return
@@ -132,27 +126,28 @@ module.exports = class TorrentController {
} }
torrentFileModtimes (torrentKey, fileModtimes) { torrentFileModtimes (torrentKey, fileModtimes) {
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
if (!torrentSummary) throw new Error('Not saving modtimes for deleted torrent ' + torrentKey)
torrentSummary.fileModtimes = fileModtimes torrentSummary.fileModtimes = fileModtimes
dispatch('saveStateThrottled') dispatch('stateSave')
} }
torrentFileSaved (torrentKey, torrentFileName) { torrentFileSaved (torrentKey, torrentFileName) {
console.log('torrent file saved %s: %s', torrentKey, torrentFileName) console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.torrentFileName = torrentFileName torrentSummary.torrentFileName = torrentFileName
dispatch('saveStateThrottled') dispatch('stateSave')
} }
torrentPosterSaved (torrentKey, posterFileName) { torrentPosterSaved (torrentKey, posterFileName) {
var torrentSummary = this.getTorrentSummary(torrentKey) const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.posterFileName = posterFileName torrentSummary.posterFileName = posterFileName
dispatch('saveStateThrottled') dispatch('stateSave')
} }
torrentAudioMetadata (infoHash, index, info) { torrentAudioMetadata (infoHash, index, info) {
var torrentSummary = this.getTorrentSummary(infoHash) const torrentSummary = this.getTorrentSummary(infoHash)
var fileSummary = torrentSummary.files[index] const fileSummary = torrentSummary.files[index]
fileSummary.audioInfo = info fileSummary.audioInfo = info
dispatch('update') dispatch('update')
} }
@@ -169,7 +164,7 @@ module.exports = class TorrentController {
} }
function getTorrentPath (torrentSummary) { function getTorrentPath (torrentSummary) {
var itemPath = TorrentSummary.getFileOrFolder(torrentSummary) let itemPath = TorrentSummary.getFileOrFolder(torrentSummary)
if (torrentSummary.files.length > 1) { if (torrentSummary.files.length > 1) {
itemPath = path.dirname(itemPath) itemPath = path.dirname(itemPath)
} }
@@ -177,14 +172,15 @@ function getTorrentPath (torrentSummary) {
} }
function showDoneNotification (torrent) { function showDoneNotification (torrent) {
var notif = new window.Notification('Download Complete', { const notif = new window.Notification('Download Complete', {
body: torrent.name, body: torrent.name,
silent: true silent: true
}) })
notif.onClick = function () { notif.onclick = function () {
ipcRenderer.send('show') ipcRenderer.send('show')
} }
sound.play('DONE') // Only play notification sound if player is inactive
if (this.state.playing.isPaused) sound.play('DONE')
} }

View File

@@ -3,7 +3,7 @@ const path = require('path')
const electron = require('electron') const electron = require('electron')
const {dispatch} = require('../lib/dispatcher') const {dispatch} = require('../lib/dispatcher')
const State = require('../lib/state') const {TorrentKeyNotFoundError} = require('../lib/errors')
const sound = require('../lib/sound') const sound = require('../lib/sound')
const TorrentSummary = require('../lib/torrent-summary') const TorrentSummary = require('../lib/torrent-summary')
@@ -17,21 +17,26 @@ module.exports = class TorrentListController {
this.state = state this.state = state
} }
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a // Adds a torrent to the list, starts downloading/seeding.
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent- // TorrentID can be a magnet URI, infohash, or torrent file: https://git.io/vik9M
addTorrent (torrentId) { addTorrent (torrentId) {
if (torrentId.path) { if (torrentId.path) {
// Use path string instead of W3C File object // Use path string instead of W3C File object
torrentId = torrentId.path torrentId = torrentId.path
} }
// Trim extra spaces off pasted magnet links
if (typeof torrentId === 'string') {
torrentId = torrentId.trim()
}
// Allow a instant.io link to be pasted // Allow a instant.io link to be pasted
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) { if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
torrentId = torrentId.slice(torrentId.indexOf('#') + 1) torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
} }
var torrentKey = this.state.nextTorrentKey++ const torrentKey = this.state.nextTorrentKey++
var path = this.state.saved.prefs.downloadPath const path = this.state.saved.prefs.downloadPath
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path) ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
@@ -64,25 +69,18 @@ module.exports = class TorrentListController {
findFilesRecursive(files, (allFiles) => this.showCreateTorrent(allFiles)) findFilesRecursive(files, (allFiles) => this.showCreateTorrent(allFiles))
} }
// Switches between the advanced and simple Create Torrent UI
toggleCreateTorrentAdvanced () {
var info = this.state.location.current()
if (info.url !== 'create-torrent') return
info.showAdvanced = !info.showAdvanced
}
// Creates a new torrent and start seeeding // Creates a new torrent and start seeeding
createTorrent (options) { createTorrent (options) {
var state = this.state const state = this.state
var torrentKey = state.nextTorrentKey++ const torrentKey = state.nextTorrentKey++
ipcRenderer.send('wt-create-torrent', torrentKey, options) ipcRenderer.send('wt-create-torrent', torrentKey, options)
state.location.cancel() state.location.cancel()
} }
// Starts downloading and/or seeding a given torrentSummary. // Starts downloading and/or seeding a given torrentSummary.
startTorrentingSummary (torrentKey) { startTorrentingSummary (torrentKey) {
var s = TorrentSummary.getByKey(this.state, torrentKey) const s = TorrentSummary.getByKey(this.state, torrentKey)
if (!s) throw new Error('Missing key: ' + torrentKey) if (!s) throw new TorrentKeyNotFoundError(torrentKey)
// New torrent: give it a path // New torrent: give it a path
if (!s.path) { if (!s.path) {
@@ -91,10 +89,16 @@ module.exports = class TorrentListController {
return start() return start()
} }
const fileOrFolder = TorrentSummary.getFileOrFolder(s)
// New torrent: metadata not yet received
if (!fileOrFolder) return start()
// Existing torrent: check that the path is still there // Existing torrent: check that the path is still there
fs.stat(TorrentSummary.getFileOrFolder(s), function (err) { fs.stat(fileOrFolder, function (err) {
if (err) { if (err) {
s.error = 'path-missing' s.error = 'path-missing'
dispatch('backToList')
return return
} }
start() start()
@@ -103,7 +107,7 @@ module.exports = class TorrentListController {
function start () { function start () {
ipcRenderer.send('wt-start-torrenting', ipcRenderer.send('wt-start-torrenting',
s.torrentKey, s.torrentKey,
TorrentSummary.getTorrentID(s), TorrentSummary.getTorrentId(s),
s.path, s.path,
s.fileModtimes, s.fileModtimes,
s.selections) s.selections)
@@ -112,24 +116,80 @@ module.exports = class TorrentListController {
// TODO: use torrentKey, not infoHash // TODO: use torrentKey, not infoHash
toggleTorrent (infoHash) { toggleTorrent (infoHash) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new' torrentSummary.status = 'new'
this.startTorrentingSummary(torrentSummary.torrentKey) this.startTorrentingSummary(torrentSummary.torrentKey)
sound.play('ENABLE') sound.play('ENABLE')
} else { return
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
} }
this.pauseTorrent(torrentSummary, true)
}
pauseAllTorrents () {
this.state.saved.torrents.forEach((torrentSummary) => {
if (torrentSummary.status === 'downloading' ||
torrentSummary.status === 'seeding') {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
}
})
sound.play('DISABLE')
}
resumeAllTorrents () {
this.state.saved.torrents.forEach((torrentSummary) => {
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'downloading'
this.startTorrentingSummary(torrentSummary.torrentKey)
}
})
sound.play('ENABLE')
}
pauseTorrent (torrentSummary, playSound) {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
if (playSound) sound.play('DISABLE')
}
prioritizeTorrent (infoHash) {
this.state.saved.torrents
.filter((torrent) => { // We're interested in active torrents only.
return (['downloading', 'seeding'].indexOf(torrent.status) !== -1)
})
.map((torrent) => { // Pause all active torrents except the one that started playing.
if (infoHash === torrent.infoHash) return
// Pause torrent without playing sounds.
this.pauseTorrent(torrent, false)
this.state.saved.torrentsToResume.push(torrent.infoHash)
})
console.log('Playback Priority: paused torrents: ', this.state.saved.torrentsToResume)
}
resumePausedTorrents () {
console.log('Playback Priority: resuming paused torrents')
this.state.saved.torrentsToResume.map((infoHash) => {
this.toggleTorrent(infoHash)
})
// reset paused torrents
this.state.saved.torrentsToResume = []
} }
toggleTorrentFile (infoHash, index) { toggleTorrentFile (infoHash, index) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
torrentSummary.selections[index] = !torrentSummary.selections[index] torrentSummary.selections[index] = !torrentSummary.selections[index]
// Let the WebTorrent process know to start or stop fetching that file // Let the WebTorrent process know to start or stop fetching that file
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections) if (torrentSummary.status !== 'paused') {
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
}
} }
confirmDeleteTorrent (infoHash, deleteData) { confirmDeleteTorrent (infoHash, deleteData) {
@@ -144,24 +204,25 @@ module.exports = class TorrentListController {
deleteTorrent (infoHash, deleteData) { deleteTorrent (infoHash, deleteData) {
ipcRenderer.send('wt-stop-torrenting', infoHash) ipcRenderer.send('wt-stop-torrenting', infoHash)
var index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash) const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) { if (index > -1) {
var summary = this.state.saved.torrents[index] const summary = this.state.saved.torrents[index]
// remove torrent and poster file // remove torrent and poster file
deleteFile(TorrentSummary.getTorrentPath(summary)) deleteFile(TorrentSummary.getTorrentPath(summary))
deleteFile(TorrentSummary.getPosterPath(summary)) // TODO: will the css path hack affect windows? deleteFile(TorrentSummary.getPosterPath(summary))
// optionally delete the torrent data // optionally delete the torrent data
if (deleteData) moveItemToTrash(summary) if (deleteData) moveItemToTrash(summary)
// remove torrent from saved list // remove torrent from saved list
this.state.saved.torrents.splice(index, 1) this.state.saved.torrents.splice(index, 1)
State.saveThrottled(this.state) dispatch('stateSave')
} }
this.state.location.clearForward('player') // prevent user from going forward to a deleted torrent // prevent user from going forward to a deleted torrent
this.state.location.clearForward('player')
sound.play('DELETE') sound.play('DELETE')
} }
@@ -174,8 +235,8 @@ module.exports = class TorrentListController {
} }
openTorrentContextMenu (infoHash) { openTorrentContextMenu (infoHash) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
var menu = new electron.remote.Menu() const menu = new electron.remote.Menu()
menu.append(new electron.remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Remove From List', label: 'Remove From List',
@@ -213,39 +274,70 @@ module.exports = class TorrentListController {
menu.append(new electron.remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Save Torrent File As...', label: 'Save Torrent File As...',
click: () => saveTorrentFileAs(torrentSummary) click: () => dispatch('saveTorrentFileAs', torrentSummary.torrentKey),
enabled: torrentSummary.torrentFileName != null
})) }))
menu.popup(electron.remote.getCurrentWindow()) menu.popup(electron.remote.getCurrentWindow())
} }
// Takes a torrentSummary or torrentKey
// Shows a Save File dialog, then saves the .torrent file wherever the user requests
saveTorrentFileAs (torrentKey) {
const torrentSummary = TorrentSummary.getByKey(this.state, torrentKey)
if (!torrentSummary) throw new Error('Missing torrentKey: ' + torrentKey)
const downloadPath = this.state.saved.prefs.downloadPath
const newFileName = path.parse(torrentSummary.name).name + '.torrent'
const win = electron.remote.getCurrentWindow()
const opts = {
title: 'Save Torrent File',
defaultPath: path.join(downloadPath, newFileName),
filters: [
{ name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] }
]
}
electron.remote.dialog.showSaveDialog(win, opts, function (savePath) {
console.log('Saving torrent ' + torrentKey + ' to ' + savePath)
if (!savePath) return // They clicked Cancel
const torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, function (err, torrentFile) {
if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, function (err) {
if (err) return dispatch('error', err)
})
})
})
}
} }
// Recursively finds {name, path, size} for all files in a folder // Recursively finds {name, path, size} for all files in a folder
// Calls `cb` on success, calls `onError` on failure // Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (paths, cb) { function findFilesRecursive (paths, cb_) {
if (paths.length > 1) { if (paths.length > 1) {
var numComplete = 0 let numComplete = 0
var ret = [] let ret = []
paths.forEach(function (path) { paths.forEach(function (path) {
findFilesRecursive([path], function (fileObjs) { findFilesRecursive([path], function (fileObjs) {
ret = ret.concat(fileObjs) ret.push(...fileObjs)
if (++numComplete === paths.length) { if (++numComplete === paths.length) {
ret.sort((a, b) => a.path < b.path ? -1 : a.path > b.path) ret.sort((a, b) => a.path < b.path ? -1 : a.path > b.path)
cb(ret) cb_(ret)
} }
}) })
}) })
return return
} }
var fileOrFolder = paths[0] const fileOrFolder = paths[0]
fs.stat(fileOrFolder, function (err, stat) { fs.stat(fileOrFolder, function (err, stat) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
// Files: return name, path, and size // Files: return name, path, and size
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
var filePath = fileOrFolder const filePath = fileOrFolder
return cb([{ return cb_([{
name: path.basename(filePath), name: path.basename(filePath),
path: filePath, path: filePath,
size: stat.size size: stat.size
@@ -253,11 +345,11 @@ function findFilesRecursive (paths, cb) {
} }
// Folders: recurse, make a list of all the files // Folders: recurse, make a list of all the files
var folderPath = fileOrFolder const folderPath = fileOrFolder
fs.readdir(folderPath, function (err, fileNames) { fs.readdir(folderPath, function (err, fileNames) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
var paths = fileNames.map((fileName) => path.join(folderPath, fileName)) const paths = fileNames.map((fileName) => path.join(folderPath, fileName))
findFilesRecursive(paths, cb) findFilesRecursive(paths, cb_)
}) })
}) })
} }
@@ -271,33 +363,10 @@ function deleteFile (path) {
// Delete all files in a torrent // Delete all files in a torrent
function moveItemToTrash (torrentSummary) { function moveItemToTrash (torrentSummary) {
var filePath = TorrentSummary.getFileOrFolder(torrentSummary) const filePath = TorrentSummary.getFileOrFolder(torrentSummary)
if (filePath) ipcRenderer.send('moveItemToTrash', filePath) if (filePath) ipcRenderer.send('moveItemToTrash', filePath)
} }
function showItemInFolder (torrentSummary) { function showItemInFolder (torrentSummary) {
ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary)) ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary))
} }
function saveTorrentFileAs (torrentSummary) {
var downloadPath = this.state.saved.prefs.downloadPath
var newFileName = path.parse(torrentSummary.name).name + '.torrent'
var opts = {
title: 'Save Torrent File',
defaultPath: path.join(downloadPath, newFileName),
filters: [
{ name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] }
]
}
electron.remote.dialog.showSaveDialog(electron.remote.getCurrentWindow(), opts, function (savePath) {
if (!savePath) return // They clicked Cancel
var torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, function (err, torrentFile) {
if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, function (err) {
if (err) return dispatch('error', err)
})
})
})
}

View File

@@ -1,4 +1,4 @@
const State = require('../lib/state') const {dispatch} = require('../lib/dispatcher')
// Controls the UI checking for new versions of the app, prompting install // Controls the UI checking for new versions of the app, prompting install
module.exports = class UpdateController { module.exports = class UpdateController {
@@ -8,7 +8,7 @@ module.exports = class UpdateController {
// Shows a modal saying that we have an update // Shows a modal saying that we have an update
updateAvailable (version) { updateAvailable (version) {
var skipped = this.state.saved.skippedVersions const skipped = this.state.saved.skippedVersions
if (skipped && skipped.includes(version)) { if (skipped && skipped.includes(version)) {
console.log('new version skipped by user: v' + version) console.log('new version skipped by user: v' + version)
return return
@@ -18,9 +18,9 @@ module.exports = class UpdateController {
// Don't show the modal again until the next version // Don't show the modal again until the next version
skipVersion (version) { skipVersion (version) {
var skipped = this.state.saved.skippedVersions let skipped = this.state.saved.skippedVersions
if (!skipped) skipped = this.state.saved.skippedVersions = [] if (!skipped) skipped = this.state.saved.skippedVersions = []
skipped.push(version) skipped.push(version)
State.saveThrottled(this.state) dispatch('stateSave')
} }
} }

View File

@@ -1,30 +0,0 @@
module.exports = captureVideoFrame
function captureVideoFrame (video, format) {
if (typeof video === 'string') {
video = document.querySelector(video)
}
if (video == null || video.nodeName !== 'VIDEO') {
throw new Error('First argument must be a <video> element or selector')
}
if (format == null) {
format = 'png'
}
if (format !== 'png' && format !== 'jpg' && format !== 'webp') {
throw new Error('Second argument must be one of "png", "jpg", or "webp"')
}
var canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
var dataUri = canvas.toDataURL('image/' + format)
var data = dataUri.split(',')[1]
return new Buffer(data, 'base64')
}

View File

@@ -13,25 +13,35 @@ module.exports = {
setRate setRate
} }
// Lazy load these for a ~300ms improvement in startup time const config = require('../../config')
var airplayer, chromecasts, dlnacasts const {CastingError} = require('./errors')
var config = require('../../config') // Lazy load these for a ~300ms improvement in startup time
let airplayer, chromecasts, dlnacasts
// App state. Cast modifies state.playing and state.errors in response to events // App state. Cast modifies state.playing and state.errors in response to events
var state let state
// Callback to notify module users when state has changed // Callback to notify module users when state has changed
var update let update
// setInterval() for updating cast status // setInterval() for updating cast status
var statusInterval = null let statusInterval = null
// Start looking for cast devices on the local network // Start looking for cast devices on the local network
function init (appState, callback) { function init (appState, callback) {
state = appState state = appState
update = callback update = callback
// Don't actually cast during integration tests
// (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.)
if (config.IS_TEST) {
state.devices.chromecast = testPlayer('chromecast')
state.devices.airplay = testPlayer('airplay')
state.devices.dlna = testPlayer('dlna')
return
}
// Load modules, scan the network for devices // Load modules, scan the network for devices
airplayer = require('airplayer')() airplayer = require('airplayer')()
chromecasts = require('chromecasts')() chromecasts = require('chromecasts')()
@@ -57,9 +67,35 @@ function init (appState, callback) {
}) })
} }
// integration test player implementation
function testPlayer (type) {
return {
getDevices,
open,
play,
pause,
stop,
status,
seek,
volume
}
function getDevices () {
return [{name: type + '-1'}, {name: type + '-2'}]
}
function open () {}
function play () {}
function pause () {}
function stop () {}
function status () {}
function seek () {}
function volume () {}
}
// chromecast player implementation // chromecast player implementation
function chromecastPlayer () { function chromecastPlayer () {
var ret = { const ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -95,8 +131,8 @@ function chromecastPlayer () {
} }
function open () { function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4', type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name title: config.APP_NAME + ' - ' + torrentSummary.name
}, function (err) { }, function (err) {
@@ -126,13 +162,7 @@ function chromecastPlayer () {
} }
function status () { function status () {
ret.device.status(function (err, status) { ret.device.status(handleStatus)
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.muted ? 0 : status.volume.level
update()
})
} }
function seek (time, callback) { function seek (time, callback) {
@@ -146,7 +176,7 @@ function chromecastPlayer () {
// airplay player implementation // airplay player implementation
function airplayPlayer () { function airplayPlayer () {
var ret = { const ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -183,7 +213,7 @@ function airplayPlayer () {
} }
function open () { function open () {
ret.device.play(state.server.networkURL, function (err, res) { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, function (err, res) {
if (err) { if (err) {
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -238,7 +268,7 @@ function airplayPlayer () {
// DLNA player implementation // DLNA player implementation
function dlnaPlayer (player) { function dlnaPlayer (player) {
var ret = { const ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -274,8 +304,8 @@ function dlnaPlayer (player) {
} }
function open () { function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4', type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name, title: config.APP_NAME + ' - ' + torrentSummary.name,
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0 seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0
@@ -306,13 +336,7 @@ function dlnaPlayer (player) {
} }
function status () { function status () {
ret.device.status(function (err, status) { ret.device.status(handleStatus)
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.level
update()
})
} }
function seek (time, callback) { function seek (time, callback) {
@@ -328,10 +352,22 @@ function dlnaPlayer (player) {
} }
} }
function handleStatus (err, status) {
if (err || !status) {
return console.log('error getting %s status: %o',
state.playing.location,
err || 'missing response')
}
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.muted ? 0 : status.volume.level
update()
}
// Start polling cast device state, whenever we're connected // Start polling cast device state, whenever we're connected
function startStatusInterval () { function startStatusInterval () {
statusInterval = setInterval(function () { statusInterval = setInterval(function () {
var player = getPlayer() const player = getPlayer()
if (player) player.status() if (player) player.status()
}, 1000) }, 1000)
} }
@@ -350,23 +386,27 @@ function toggleMenu (location) {
// Never cast to two devices at the same time // Never cast to two devices at the same time
if (state.playing.location !== 'local') { if (state.playing.location !== 'local') {
throw new Error('You can\'t connect to ' + location + ' when already connected to another device') throw new CastingError(
`You can't connect to ${location} when already connected to another device`
)
} }
// Find all cast devices of the given type // Find all cast devices of the given type
var player = getPlayer(location) const player = getPlayer(location)
var devices = player ? player.getDevices() : [] const devices = player ? player.getDevices() : []
if (devices.length === 0) throw new Error('No ' + location + ' devices available') if (devices.length === 0) {
throw new CastingError(`No ${location} devices available`)
}
// Show a menu // Show a menu
state.devices.castMenu = {location, devices} state.devices.castMenu = {location, devices}
} }
function selectDevice (index) { function selectDevice (index) {
var {location, devices} = state.devices.castMenu const {location, devices} = state.devices.castMenu
// Start casting // Start casting
var player = getPlayer(location) const player = getPlayer(location)
player.device = devices[index] player.device = devices[index]
player.open() player.open()
@@ -382,7 +422,7 @@ function selectDevice (index) {
// Stops casting, move video back to local screen // Stops casting, move video back to local screen
function stop () { function stop () {
var player = getPlayer() const player = getPlayer()
if (player) { if (player) {
player.stop(function () { player.stop(function () {
player.device = null player.device = null
@@ -417,18 +457,18 @@ function getPlayer (location) {
} }
function play () { function play () {
var player = getPlayer() const player = getPlayer()
if (player) player.play(castCallback) if (player) player.play(castCallback)
} }
function pause () { function pause () {
var player = getPlayer() const player = getPlayer()
if (player) player.pause(castCallback) if (player) player.pause(castCallback)
} }
function setRate (rate) { function setRate (rate) {
var player let player
var result = true let result = true
if (state.playing.location === 'chromecast') { if (state.playing.location === 'chromecast') {
// TODO find how to control playback rate on chromecast // TODO find how to control playback rate on chromecast
castCallback() castCallback()
@@ -443,12 +483,12 @@ function setRate (rate) {
} }
function seek (time) { function seek (time) {
var player = getPlayer() const player = getPlayer()
if (player) player.seek(time, castCallback) if (player) player.seek(time, castCallback)
} }
function setVolume (volume) { function setVolume (volume) {
var player = getPlayer() const player = getPlayer()
if (player) player.volume(volume, castCallback) if (player) player.volume(volume, castCallback)
} }

View File

@@ -4,8 +4,8 @@ module.exports = {
setDispatch setDispatch
} }
var dispatchers = {} const dispatchers = {}
var _dispatch = function () {} let _dispatch = function () {}
function setDispatch (dispatch) { function setDispatch (dispatch) {
_dispatch = dispatch _dispatch = dispatch
@@ -20,8 +20,8 @@ function dispatch (...args) {
// function. This prevents React from updating the listener functions on // function. This prevents React from updating the listener functions on
// each update(). // each update().
function dispatcher (...args) { function dispatcher (...args) {
var str = JSON.stringify(args) const str = JSON.stringify(args)
var handler = dispatchers[str] let handler = dispatchers[str]
if (!handler) { if (!handler) {
handler = dispatchers[str] = function (e) { handler = dispatchers[str] = function (e) {
// Do not propagate click to elements below the button // Do not propagate click to elements below the button

View File

@@ -1,8 +1,41 @@
module.exports = { const ExtendableError = require('es6-error')
UnplayableError
/* Generic errors */
class CastingError extends ExtendableError {}
class PlaybackError extends ExtendableError {}
class SoundError extends ExtendableError {}
class TorrentError extends ExtendableError {}
/* Playback */
class UnplayableTorrentError extends PlaybackError {
constructor () { super('Can\'t play any files in torrent') }
} }
function UnplayableError () { class UnplayableFileError extends PlaybackError {
this.message = 'Can\'t play any files in torrent' constructor () { super('Can\'t play that file') }
}
/* Sound */
class InvalidSoundNameError extends SoundError {
constructor (name) { super(`Invalid sound name: ${name}`) }
}
/* Torrent */
class TorrentKeyNotFoundError extends TorrentError {
constructor (torrentKey) { super(`Can't resolve torrent key ${torrentKey}`) }
}
module.exports = {
CastingError,
PlaybackError,
SoundError,
TorrentError,
UnplayableTorrentError,
UnplayableFileError,
InvalidSoundNameError,
TorrentKeyNotFoundError
} }
UnplayableError.prototype = Error

View File

@@ -4,10 +4,11 @@ module.exports = {
run run
} }
const semver = require('semver')
const config = require('../../config')
const TorrentSummary = require('./torrent-summary')
const fs = require('fs') const fs = require('fs')
const path = require('path')
const semver = require('semver')
const config = require('../../config')
// Change `state.saved` (which will be saved back to config.json on exit) as // 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 // needed, for example to deal with config.json format changes across versions
@@ -17,34 +18,27 @@ function run (state) {
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
} }
var version = state.saved.version const version = state.saved.version
const saved = state.saved
if (semver.lt(version, '0.7.0')) { if (semver.lt(version, '0.7.0')) migrate_0_7_0(saved)
migrate_0_7_0(state.saved) if (semver.lt(version, '0.7.2')) migrate_0_7_2(saved)
} if (semver.lt(version, '0.11.0')) migrate_0_11_0(saved)
if (semver.lt(version, '0.12.0')) migrate_0_12_0(saved)
if (semver.lt(version, '0.7.2')) { if (semver.lt(version, '0.14.0')) migrate_0_14_0(saved)
migrate_0_7_2(state.saved) if (semver.lt(version, '0.17.0')) migrate_0_17_0(saved)
} if (semver.lt(version, '0.17.2')) migrate_0_17_2(saved)
if (semver.lt(version, '0.11.0')) {
migrate_0_11_0(state.saved)
}
if (semver.lt(version, '0.12.0')) {
migrate_0_12_0(state.saved)
}
// Config is now on the new version // Config is now on the new version
state.saved.version = config.APP_VERSION state.saved.version = config.APP_VERSION
} }
function migrate_0_7_0 (saved) { function migrate_0_7_0 (saved) {
var fs = require('fs-extra') const cpFile = require('cp-file')
var path = require('path') const path = require('path')
saved.torrents.forEach(function (ts) { saved.torrents.forEach(function (ts) {
var infoHash = ts.infoHash const infoHash = ts.infoHash
// Replace torrentPath with torrentFileName // Replace torrentPath with torrentFileName
// There are a number of cases to handle here: // There are a number of cases to handle here:
@@ -52,7 +46,7 @@ function migrate_0_7_0 (saved) {
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent' // * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent' // * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
// * Finally, now we're getting rid of torrentPath altogether // * Finally, now we're getting rid of torrentPath altogether
var src, dst let src, dst
if (ts.torrentPath) { if (ts.torrentPath) {
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) { if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
src = ts.torrentPath src = ts.torrentPath
@@ -62,7 +56,7 @@ function migrate_0_7_0 (saved) {
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent') dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration // Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once // that only runs once
if (src !== dst) fs.copySync(src, dst) if (src !== dst) cpFile.sync(src, dst)
delete ts.torrentPath delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent' ts.torrentFileName = infoHash + '.torrent'
@@ -70,21 +64,21 @@ function migrate_0_7_0 (saved) {
// Replace posterURL with posterFileName // Replace posterURL with posterFileName
if (ts.posterURL) { if (ts.posterURL) {
var extension = path.extname(ts.posterURL) const extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL) src = path.isAbsolute(ts.posterURL)
? ts.posterURL ? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL) : path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.POSTER_PATH, infoHash + extension) dst = path.join(config.POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration // Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once // that only runs once
if (src !== dst) fs.copySync(src, dst) if (src !== dst) cpFile.sync(src, dst)
delete ts.posterURL delete ts.posterURL
ts.posterFileName = infoHash + extension ts.posterFileName = infoHash + extension
} }
// Fix exception caused by incorrect file ordering. // Fix exception caused by incorrect file ordering.
// https://github.com/feross/webtorrent-desktop/pull/604#issuecomment-222805214 // https://github.com/webtorrent/webtorrent-desktop/pull/604#issuecomment-222805214
delete ts.defaultPlayFileIndex delete ts.defaultPlayFileIndex
delete ts.files delete ts.files
delete ts.selections delete ts.selections
@@ -108,6 +102,8 @@ function migrate_0_11_0 (saved) {
} }
function migrate_0_12_0 (saved) { function migrate_0_12_0 (saved) {
const TorrentSummary = require('./torrent-summary')
if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) { if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) {
saved.prefs.openExternalPlayer = saved.prefs.playInVlc saved.prefs.openExternalPlayer = saved.prefs.playInVlc
} }
@@ -115,8 +111,8 @@ function migrate_0_12_0 (saved) {
// Undo a terrible bug where clicking Play on a default torrent on a fresh // Undo a terrible bug where clicking Play on a default torrent on a fresh
// install results in a "path missing" error // install results in a "path missing" error
// See https://github.com/feross/webtorrent-desktop/pull/806 // See https://github.com/webtorrent/webtorrent-desktop/pull/806
var defaultTorrentFiles = [ const defaultTorrentFiles = [
'6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent', '6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent',
'88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent', '88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent',
'6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent', '6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent',
@@ -125,13 +121,88 @@ function migrate_0_12_0 (saved) {
] ]
saved.torrents.forEach(function (torrentSummary) { saved.torrents.forEach(function (torrentSummary) {
if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return
var fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary) const fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary)
if (!fileOrFolder) return if (!fileOrFolder) return
try { try {
fs.statSync(fileOrFolder) fs.statSync(fileOrFolder)
} catch (e) { } catch (err) {
// Default torrent with "missing path" error. Clear path. // Default torrent with "missing path" error. Clear path.
delete torrentSummary.path delete torrentSummary.path
} }
}) })
} }
function migrate_0_14_0 (saved) {
saved.torrents.forEach(function (ts) {
delete ts.defaultPlayFileIndex
})
}
function migrate_0_17_0 (saved) {
// Fix a sad, sad bug that resulted in 100MB+ config.json files
saved.torrents.forEach(function (ts) {
if (!ts.files) return
ts.files.forEach(function (file) {
if (!file.audioInfo || !file.audioInfo.picture) return
// This contained a Buffer, which 30x'd in size when serialized to JSON
delete file.audioInfo.picture
})
})
}
function migrate_0_17_2 (saved) {
// Remove the trailing dot (.) from the Wired CD torrent name, since
// folders/files that end in a trailing dot (.) or space are not deletable from
// Windows Explorer. See: https://github.com/webtorrent/webtorrent-desktop/issues/905
const cpFile = require('cp-file')
const rimraf = require('rimraf')
const OLD_NAME = 'The WIRED CD - Rip. Sample. Mash. Share.'
const NEW_NAME = 'The WIRED CD - Rip. Sample. Mash. Share'
const OLD_HASH = '3ba219a8634bf7bae3d848192b2da75ae995589d'
const NEW_HASH = 'a88fda5954e89178c372716a6a78b8180ed4dad3'
const ts = saved.torrents.find((ts) => {
return ts.infoHash === OLD_HASH
})
if (!ts) return // Wired CD torrent does not exist
// New versions of WebTorrent ship with a fixed torrent file. Let's fix up the
// name in existing versions of WebTorrent.
ts.name = ts.displayName = NEW_NAME
ts.files.forEach((file) => {
file.path = file.path.replace(OLD_NAME, NEW_NAME)
})
// Changing the torrent name causes the info hash to change
ts.infoHash = NEW_HASH
ts.magnetURI = ts.magnetURI.replace(OLD_HASH, NEW_HASH)
try {
fs.renameSync(
path.join(config.POSTER_PATH, ts.posterFileName),
path.join(config.POSTER_PATH, NEW_HASH + '.jpg')
)
} catch (err) {}
ts.posterFileName = NEW_HASH + '.jpg'
rimraf.sync(path.join(config.TORRENT_PATH, ts.torrentFileName))
cpFile.sync(
path.join(config.STATIC_PATH, 'wiredCd.torrent'),
path.join(config.TORRENT_PATH, NEW_HASH + '.torrent')
)
ts.torrentFileName = NEW_HASH + '.torrent'
if (ts.path) {
// If torrent folder already exists on disk, try to rename it
try {
fs.renameSync(
path.join(ts.path, OLD_NAME),
path.join(ts.path, NEW_NAME)
)
} catch (err) {}
}
}

View File

@@ -0,0 +1,87 @@
module.exports = {
hasNext,
getNextIndex,
hasPrevious,
getPreviousIndex,
getCurrentLocalURL
}
const TorrentSummary = require('./torrent-summary')
const TorrentPlayer = require('./torrent-player')
const cache = {
infoHash: null,
previousIndex: null,
currentIndex: null,
nextIndex: null
}
function hasNext (state) {
updateCache(state)
return cache.nextIndex !== null
}
function getNextIndex (state) {
updateCache(state)
return cache.nextIndex
}
function hasPrevious (state) {
updateCache(state)
return cache.previousIndex !== null
}
function getPreviousIndex (state) {
updateCache(state)
return cache.previousIndex
}
function getCurrentLocalURL (state) {
return state.server
? state.server.localURL + '/' + state.playing.fileIndex
: ''
}
function updateCache (state) {
const infoHash = state.playing.infoHash
const fileIndex = state.playing.fileIndex
if (infoHash === cache.infoHash) {
switch (fileIndex) {
case cache.currentIndex:
return
case cache.nextIndex:
cache.previousIndex = cache.currentIndex
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
return
case cache.previousIndex:
cache.previousIndex = findPreviousIndex(state)
cache.nextIndex = cache.currentIndex
cache.currentIndex = fileIndex
return
}
} else {
cache.infoHash = infoHash
}
cache.previousIndex = findPreviousIndex(state)
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
}
function findPreviousIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex - 1; i >= 0; i--) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}
function findNextIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex + 1; i < files.length; i++) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}

View File

@@ -1,28 +1,28 @@
module.exports = { module.exports = {
preload,
play play
} }
var config = require('../../config') const config = require('../../config')
var path = require('path') const {InvalidSoundNameError} = require('./errors')
const path = require('path')
var VOLUME = 0.15 const VOLUME = 0.25
/* Cache of Audio elements, for instant playback */ /* Cache of Audio elements, for instant playback */
var cache = {} const cache = {}
var sounds = { const sounds = {
ADD: { ADD: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
volume: VOLUME volume: VOLUME
}, },
DELETE: { DELETE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
volume: VOLUME volume: VOLUME * 0.5
}, },
DISABLE: { DISABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
volume: VOLUME volume: VOLUME * 0.5
}, },
DONE: { DONE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
@@ -30,7 +30,7 @@ var sounds = {
}, },
ENABLE: { ENABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
volume: VOLUME volume: VOLUME * 0.5
}, },
ERROR: { ERROR: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
@@ -42,27 +42,16 @@ var sounds = {
}, },
STARTUP: { STARTUP: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
volume: VOLUME * 2 volume: VOLUME
}
}
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) { function play (name) {
var audio = cache[name] let audio = cache[name]
if (!audio) { if (!audio) {
var sound = sounds[name] const sound = sounds[name]
if (!sound) { if (!sound) {
throw new Error('Invalid sound name') throw new InvalidSoundNameError(name)
} }
audio = cache[name] = new window.Audio() audio = cache[name] = new window.Audio()
audio.volume = sound.volume audio.volume = sound.volume

View File

@@ -1,21 +1,30 @@
var appConfig = require('application-config')('WebTorrent') const appConfig = require('application-config')('WebTorrent')
var path = require('path') const path = require('path')
var {EventEmitter} = require('events') const {EventEmitter} = require('events')
var config = require('../../config') const config = require('../../config')
var migrations = require('./migrations')
var State = module.exports = Object.assign(new EventEmitter(), { const SAVE_DEBOUNCE_INTERVAL = 1000
getDefaultPlayState,
load,
save,
saveThrottled
})
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json') appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
const State = module.exports = Object.assign(new EventEmitter(), {
getDefaultPlayState,
load,
// state.save() calls are rate-limited. Use state.saveImmediate() to skip limit.
save: function () {
// Perf optimization: Lazy-require debounce (and it's dependencies)
const debounce = require('debounce')
// After first State.save() invokation, future calls go straight to the
// debounced function
State.save = debounce(saveImmediate, SAVE_DEBOUNCE_INTERVAL)
State.save(...arguments)
},
saveImmediate
})
function getDefaultState () { function getDefaultState () {
var LocationHistory = require('location-history') const LocationHistory = require('location-history')
return { return {
/* /*
@@ -24,7 +33,11 @@ function getDefaultState () {
*/ */
client: null, /* the WebTorrent client */ client: null, /* the WebTorrent client */
server: null, /* local WebTorrent-to-HTTP server */ server: null, /* local WebTorrent-to-HTTP server */
prev: {}, /* used for state diffing in updateElectron() */ prev: { /* used for state diffing in updateElectron() */
title: null,
progress: -1,
badge: null
},
location: new LocationHistory(), location: new LocationHistory(),
window: { window: {
bounds: null, /* {x, y, width, height } */ bounds: null, /* {x, y, width, height } */
@@ -64,7 +77,9 @@ function getDefaultState () {
* Getters, for convenience * Getters, for convenience
*/ */
getPlayingTorrentSummary, getPlayingTorrentSummary,
getPlayingFileSummary getPlayingFileSummary,
getExternalPlayerName,
shouldHidePlayerControls
} }
} }
@@ -77,6 +92,7 @@ function getDefaultPlayState () {
type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */ type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */
currentTime: 0, /* seconds */ currentTime: 0, /* seconds */
duration: 1, /* seconds */ duration: 1, /* seconds */
isReady: false,
isPaused: true, isPaused: true,
isStalled: false, isStalled: false,
lastTimeUpdate: 0, /* Unix time in ms */ lastTimeUpdate: 0, /* Unix time in ms */
@@ -93,39 +109,40 @@ function getDefaultPlayState () {
} }
/* If the saved state file doesn't exist yet, here's what we use instead */ /* If the saved state file doesn't exist yet, here's what we use instead */
function setupSavedState (cb) { function setupStateSaved (cb) {
var fs = require('fs-extra') const cpFile = require('cp-file')
var parseTorrent = require('parse-torrent') const fs = require('fs')
var parallel = require('run-parallel') const parseTorrent = require('parse-torrent')
const parallel = require('run-parallel')
var saved = { const saved = {
prefs: { prefs: {
downloadPath: config.DEFAULT_DOWNLOAD_PATH, downloadPath: config.DEFAULT_DOWNLOAD_PATH,
isFileHandler: false, isFileHandler: false,
openExternalPlayer: false, openExternalPlayer: false,
externalPlayerPath: null externalPlayerPath: null,
startup: false
}, },
torrents: config.DEFAULT_TORRENTS.map(createTorrentObject), torrents: config.DEFAULT_TORRENTS.map(createTorrentObject),
torrentsToResume: [],
version: config.APP_VERSION /* make sure we can upgrade gracefully later */ version: config.APP_VERSION /* make sure we can upgrade gracefully later */
} }
var tasks = [] const tasks = []
config.DEFAULT_TORRENTS.map(function (t, i) { config.DEFAULT_TORRENTS.map(function (t, i) {
var infoHash = saved.torrents[i].infoHash const infoHash = saved.torrents[i].infoHash
tasks.push(function (cb) { tasks.push(function (cb) {
fs.copy( cpFile(
path.join(config.STATIC_PATH, t.posterFileName), path.join(config.STATIC_PATH, t.posterFileName),
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)), path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName))
cb ).then(cb).catch(cb)
)
}) })
tasks.push(function (cb) { tasks.push(function (cb) {
fs.copy( cpFile(
path.join(config.STATIC_PATH, t.torrentFileName), path.join(config.STATIC_PATH, t.torrentFileName),
path.join(config.TORRENT_PATH, infoHash + '.torrent'), path.join(config.TORRENT_PATH, infoHash + '.torrent')
cb ).then(cb).catch(cb)
)
}) })
}) })
@@ -135,8 +152,9 @@ function setupSavedState (cb) {
}) })
function createTorrentObject (t) { function createTorrentObject (t) {
var torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName)) // TODO: Doing several fs.readFileSync calls during first startup is not ideal
var parsedTorrent = parseTorrent(torrent) const torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
const parsedTorrent = parseTorrent(torrent)
return { return {
status: 'paused', status: 'paused',
@@ -147,62 +165,80 @@ function setupSavedState (cb) {
torrentFileName: parsedTorrent.infoHash + '.torrent', torrentFileName: parsedTorrent.infoHash + '.torrent',
magnetURI: parseTorrent.toMagnetURI(parsedTorrent), magnetURI: parseTorrent.toMagnetURI(parsedTorrent),
files: parsedTorrent.files, files: parsedTorrent.files,
selections: parsedTorrent.files.map((x) => true) selections: parsedTorrent.files.map((x) => true),
testID: t.testID
} }
} }
} }
function getPlayingTorrentSummary () { function getPlayingTorrentSummary () {
var infoHash = this.playing.infoHash const infoHash = this.playing.infoHash
return this.saved.torrents.find((x) => x.infoHash === infoHash) return this.saved.torrents.find((x) => x.infoHash === infoHash)
} }
function getPlayingFileSummary () { function getPlayingFileSummary () {
var torrentSummary = this.getPlayingTorrentSummary() const torrentSummary = this.getPlayingTorrentSummary()
if (!torrentSummary) return null if (!torrentSummary) return null
return torrentSummary.files[this.playing.fileIndex] return torrentSummary.files[this.playing.fileIndex]
} }
function load (cb) { function getExternalPlayerName () {
var state = getDefaultState() const playerPath = this.saved.prefs.externalPlayerPath
if (!playerPath) return 'VLC'
return path.basename(playerPath).split('.')[0]
}
function shouldHidePlayerControls () {
return this.location.url() === 'player' &&
this.playing.mouseStationarySince !== 0 &&
new Date().getTime() - this.playing.mouseStationarySince > 2000 &&
!this.playing.mouseInControls &&
!this.playing.isPaused &&
this.playing.location === 'local' &&
this.playing.playbackRate === 1
}
function load (cb) {
appConfig.read(function (err, saved) { appConfig.read(function (err, saved) {
if (err || !saved.version) { if (err || !saved.version) {
console.log('Missing config file: Creating new one') console.log('Missing config file: Creating new one')
setupSavedState(onSaved) setupStateSaved(onSavedState)
} else { } else {
onSaved(null, saved) onSavedState(null, saved)
} }
}) })
function onSaved (err, saved) { function onSavedState (err, saved) {
if (err) return cb(err) if (err) return cb(err)
const state = getDefaultState()
state.saved = saved state.saved = saved
migrations.run(state)
if (process.type === 'renderer') {
// Perf optimization: Save require() calls in the main process
const migrations = require('./migrations')
migrations.run(state)
}
cb(null, state) cb(null, state)
} }
} }
// Write state.saved to the JSON state file // Write state.saved to the JSON state file
function save (state, cb) { function saveImmediate (state, cb) {
console.log('Saving state to ' + appConfig.filePath) console.log('Saving state to ' + appConfig.filePath)
delete state.saveStateTimeout
// 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) const copy = Object.assign({}, state.saved)
// Remove torrents pending addition to the list, where we haven't finished // Remove torrents pending addition to the list, where we haven't finished
// reading the torrent file or file(s) to seed & don't have an infohash // reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents copy.torrents = copy.torrents
.filter((x) => x.infoHash) .filter((x) => x.infoHash)
.map(function (x) { .map(function (x) {
var torrent = {} const torrent = {}
for (var key in x) { for (let key in x) {
if (key === 'progress' || key === 'torrentKey') { if (key === 'progress' || key === 'torrentKey') {
continue // Don't save progress info or key for the webtorrent process continue // Don't save progress info or key for the webtorrent process
} }
if (key === 'playStatus') {
continue // Don't save whether a torrent is playing / pending
}
if (key === 'error') { if (key === 'error') {
continue // Don't save error states continue // Don't save error states
} }
@@ -213,15 +249,6 @@ function save (state, cb) {
appConfig.write(copy, (err) => { appConfig.write(copy, (err) => {
if (err) console.error(err) if (err) console.error(err)
else State.emit('savedState') else State.emit('stateSaved')
}) })
} }
// Write, but no more than once a second
function saveThrottled (state) {
if (state.saveStateTimeout) return
state.saveStateTimeout = setTimeout(function () {
if (!state.saveStateTimeout) return
save(state)
}, 1000)
}

View File

@@ -2,92 +2,76 @@
// Reports back so that we can improve WebTorrent Desktop // Reports back so that we can improve WebTorrent Desktop
module.exports = { module.exports = {
init, init,
send,
logUncaughtError, logUncaughtError,
logPlayAttempt logPlayAttempt
} }
const crypto = require('crypto')
const electron = require('electron') const electron = require('electron')
const https = require('https')
const os = require('os')
const url = require('url')
const config = require('../../config') const config = require('../../config')
var telemetry let telemetry
function init (state) { function init (state) {
telemetry = state.saved.telemetry telemetry = state.saved.telemetry
// First app run
if (!telemetry) { if (!telemetry) {
telemetry = state.saved.telemetry = createSummary() const crypto = require('crypto')
telemetry = state.saved.telemetry = {
userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID
}
reset() reset()
} }
}
var now = new Date() function send (state) {
const now = new Date()
telemetry.version = config.APP_VERSION telemetry.version = config.APP_VERSION
telemetry.timestamp = now.toISOString() telemetry.timestamp = now.toISOString()
telemetry.localTime = now.toTimeString() telemetry.localTime = now.toTimeString()
telemetry.screens = getScreenInfo() telemetry.screens = getScreenInfo()
telemetry.system = getSystemInfo() telemetry.system = getSystemInfo()
telemetry.approxNumTorrents = getApproxNumTorrents(state) telemetry.torrentStats = getTorrentStats(state)
telemetry.approxNumTorrents = telemetry.torrentStats.approxCount
if (config.IS_PRODUCTION) { if (!config.IS_PRODUCTION) {
postToServer()
// If the user keeps WebTorrent running for a long time, post every 24h
setInterval(postToServer, 12 * 3600 * 1000)
} else {
// Development: telemetry used only for local debugging // Development: telemetry used only for local debugging
// Empty uncaught errors, etc at the start of every run // Empty uncaught errors, etc at the start of every run
reset() return reset()
} }
const get = require('simple-get')
const opts = {
url: config.TELEMETRY_URL,
body: telemetry,
json: true
}
get.post(opts, function (err, res) {
if (err) return console.error('Error sending telemetry', err)
if (res.statusCode !== 200) {
return console.error(`Error sending telemetry, status code: ${res.statusCode}`)
}
console.log('Sent telemetry')
reset()
})
} }
function reset () { function reset () {
telemetry.uncaughtErrors = [] telemetry.uncaughtErrors = []
telemetry.playAttempts = { telemetry.playAttempts = {
minVersion: config.APP_VERSION,
total: 0, total: 0,
success: 0, success: 0,
timeout: 0,
error: 0, error: 0,
external: 0,
abandoned: 0 abandoned: 0
} }
} }
function postToServer () {
// Serialize the telemetry summary
var payload = new Buffer(JSON.stringify(telemetry), 'utf8')
// POST to our server
var options = url.parse(config.TELEMETRY_URL)
options.method = 'POST'
options.headers = {
'Content-Type': 'application/json',
'Content-Length': payload.length
}
var req = https.request(options, function (res) {
if (res.statusCode === 200) {
console.log('Successfully posted telemetry summary')
reset()
} else {
console.error('Couldn\'t post telemetry summary, got HTTP ' + res.statusCode)
}
})
req.on('error', function (e) {
console.error('Couldn\'t post telemetry summary', e)
})
req.write(payload)
req.end()
}
// Creates a new telemetry summary. Gives the user a unique ID,
// collects screen resolution, etc
function createSummary () {
// Make a 256-bit random unique ID
var userID = crypto.randomBytes(32).toString('hex')
return { userID }
}
// Track screen resolution // Track screen resolution
function getScreenInfo () { function getScreenInfo () {
return electron.screen.getAllDisplays().map((screen) => ({ return electron.screen.getAllDisplays().map((screen) => ({
@@ -99,58 +83,141 @@ function getScreenInfo () {
// Track basic system info like OS version and amount of RAM // Track basic system info like OS version and amount of RAM
function getSystemInfo () { function getSystemInfo () {
const os = require('os')
return { return {
osPlatform: process.platform, osPlatform: process.platform,
osRelease: os.type() + ' ' + os.release(), osRelease: os.type() + ' ' + os.release(),
architecture: os.arch(), architecture: os.arch(),
totalMemoryMB: os.totalmem() / (1 << 20), systemArchitecture: config.OS_SYSARCH,
totalMemoryMB: roundPow2(os.totalmem() / (1 << 20)),
numCores: os.cpus().length numCores: os.cpus().length
} }
} }
// Get the number of torrents, rounded to the nearest power of two // Get stats like the # of torrents currently active, # in list, total size
function getApproxNumTorrents (state) { function getTorrentStats (state) {
var exactNum = state.saved.torrents.length const count = state.saved.torrents.length
if (exactNum === 0) return 0 let sizeMB = 0
let byStatus = {
new: { count: 0, sizeMB: 0 },
downloading: { count: 0, sizeMB: 0 },
seeding: { count: 0, sizeMB: 0 },
paused: { count: 0, sizeMB: 0 }
}
// First, count torrents & total file size
for (let i = 0; i < count; i++) {
const t = state.saved.torrents[i]
const stat = byStatus[t.status]
if (!t || !t.files || !stat) continue
stat.count++
for (let j = 0; j < t.files.length; j++) {
const f = t.files[j]
if (!f || !f.length) continue
const fileSizeMB = f.length / (1 << 20)
sizeMB += fileSizeMB
stat.sizeMB += fileSizeMB
}
}
// Then, round all the counts and sums to the nearest power of 2
const ret = roundTorrentStats({count, sizeMB})
ret.byStatus = {
new: roundTorrentStats(byStatus.new),
downloading: roundTorrentStats(byStatus.downloading),
seeding: roundTorrentStats(byStatus.seeding),
paused: roundTorrentStats(byStatus.paused)
}
return ret
}
function roundTorrentStats (stats) {
return {
approxCount: roundPow2(stats.count),
approxSizeMB: roundPow2(stats.sizeMB)
}
}
// Rounds to the nearest power of 2, for privacy and easy bucketing.
// Rounds 35 to 32, 70 to 64, 5 to 4, 1 to 1, 0 to 0.
// Supports nonnegative numbers only.
function roundPow2 (n) {
if (n <= 0) return 0
// Otherwise, return 1, 2, 4, 8, etc by rounding in log space // Otherwise, return 1, 2, 4, 8, etc by rounding in log space
var log2 = Math.log(exactNum) / Math.log(2) const log2 = Math.log(n) / Math.log(2)
return 1 << Math.round(log2) return Math.pow(2, Math.round(log2))
} }
// An uncaught error happened in the main process or in one of the windows // An uncaught error happened in the main process or in one of the windows
function logUncaughtError (procName, err) { function logUncaughtError (procName, e) {
// Not initialized yet? Ignore. // Not initialized yet? Ignore.
// Hopefully uncaught errors immediately on startup are fixed in dev // Hopefully uncaught errors immediately on startup are fixed in dev
if (!telemetry) return if (!telemetry) return
var message, stack let message
if (err instanceof Error) { let stack = ''
message = err.message if (e == null) {
// Remove the first part of each file path in the stack trace. message = 'Unexpected undefined error'
// - Privacy: remove personal info like C:\Users\<full name> } else if (e.error) {
// - Aggregation: this lets us find which stacktraces occur often // Uncaught Javascript errors (window.onerror), err is an ErrorEvent
stack = err.stack.replace(/\(.*app.asar/g, '(...') if (!e.error.message) {
message = 'Unexpected ErrorEvent.error: ' + Object.keys(e.error).join(' ')
} else {
message = e.error.message
stack = e.error.stack
}
} else if (e.message) {
// err is either an Error or a plain object {message, stack}
message = e.message
stack = e.stack
} else { } else {
message = String(err) // Resource errors (captured element.onerror), err is an Event
stack = '' if (!e.target) {
message = 'Unexpected unknown error'
} else if (!e.target.error) {
message = 'Unexpected resource loading error: ' + getElemString(e.target)
} else {
message = 'Resource error ' + getElemString(e.target) + ': ' + e.target.error.code
}
} }
if (typeof stack !== 'string') stack = 'Unexpected stack: ' + stack
if (typeof message !== 'string') message = 'Unexpected message: ' + message
// Remove the first part of each file path in the stack trace.
// - Privacy: remove personal info like C:\Users\<full name>
// - Aggregation: this lets us find which stacktraces occur often
stack = stack.replace(/\(.*app.asar/g, '(...')
stack = stack.replace(/at .*app.asar/g, 'at ...')
// We need to POST the telemetry object, make sure it stays < 100kb // We need to POST the telemetry object, make sure it stays < 100kb
if (telemetry.uncaughtErrors.length > 20) return if (telemetry.uncaughtErrors.length > 20) return
if (message.length > 1000) message = message.substring(0, 1000) if (message.length > 1000) message = message.substring(0, 1000)
if (stack.length > 1000) stack = stack.substring(0, 1000) if (stack.length > 1000) stack = stack.substring(0, 1000)
telemetry.uncaughtErrors.push({process: procName, message, stack}) // Log the app version *at the time of the error*
const version = config.APP_VERSION
telemetry.uncaughtErrors.push({process: procName, message, stack, version})
} }
// The user pressed play. It either worked, timed out, or showed the // Turns a DOM element into a string, eg "DIV.my-class.visible"
// 'Play in VLC' codec error function getElemString (elem) {
let ret = elem.tagName
try {
ret += '.' + Array.from(elem.classList).join('.')
} catch (err) {}
return ret
}
// The user pressed play. Did it work, display an error,
// open an external player or did user abandon the attempt?
function logPlayAttempt (result) { function logPlayAttempt (result) {
if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) { if (!['success', 'error', 'external', 'abandoned'].includes(result)) {
return console.error('Unknown play attempt result', result) return console.error('Unknown play attempt result', result)
} }
var attempts = telemetry.playAttempts const attempts = telemetry.playAttempts
attempts.total = (attempts.total || 0) + 1 attempts.total = (attempts.total || 0) + 1
attempts[result] = (attempts[result] || 0) + 1 attempts[result] = (attempts[result] || 0) + 1
} }

View File

@@ -3,11 +3,10 @@ module.exports = {
isVideo, isVideo,
isAudio, isAudio,
isTorrent, isTorrent,
isPlayableTorrentSummary, isPlayableTorrentSummary
pickFileToPlay
} }
var path = require('path') const path = require('path')
// Checks whether a fileSummary or file path is audio/video that we can play, // Checks whether a fileSummary or file path is audio/video that we can play,
// based on the file extension // based on the file extension
@@ -37,7 +36,9 @@ function isAudio (file) {
'.ac3', '.ac3',
'.mp3', '.mp3',
'.ogg', '.ogg',
'.wav' '.wav',
'.flac',
'.m4a'
].includes(getFileExtension(file)) ].includes(getFileExtension(file))
} }
@@ -46,38 +47,16 @@ function isAudio (file) {
// - a file object where obj.name is ends in .torrent // - a file object where obj.name is ends in .torrent
// - a string that's a magnet link (magnet://...) // - a string that's a magnet link (magnet://...)
function isTorrent (file) { function isTorrent (file) {
var isTorrentFile = getFileExtension(file) === '.torrent' const isTorrentFile = getFileExtension(file) === '.torrent'
var isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file) const isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file)
return isTorrentFile || isMagnet return isTorrentFile || isMagnet
} }
function getFileExtension (file) { function getFileExtension (file) {
var name = typeof file === 'string' ? file : file.name const name = typeof file === 'string' ? file : file.name
return path.extname(name).toLowerCase() return path.extname(name).toLowerCase()
} }
function isPlayableTorrentSummary (torrentSummary) { function isPlayableTorrentSummary (torrentSummary) {
return torrentSummary.files && torrentSummary.files.some(isPlayable) return torrentSummary.files && torrentSummary.files.some(isPlayable)
} }
// 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(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(isAudio)
if (audioFiles.length > 0) {
return files.indexOf(audioFiles[0])
}
// no video or audio means nothing is playable
return undefined
}

View File

@@ -1,22 +1,22 @@
module.exports = torrentPoster module.exports = torrentPoster
var captureVideoFrame = require('./capture-video-frame') const captureFrame = require('capture-frame')
var path = require('path') const path = require('path')
function torrentPoster (torrent, cb) { function torrentPoster (torrent, cb) {
// First, try to use a poster image if available // First, try to use a poster image if available
var posterFile = torrent.files.filter(function (file) { const posterFile = torrent.files.filter(function (file) {
return /^poster\.(jpg|png|gif)$/.test(file.name) return /^poster\.(jpg|png|gif)$/.test(file.name)
})[0] })[0]
if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb) if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb)
// Second, try to use the largest video file // Second, try to use the largest video file
// Filter out file formats that the <video> tag definitely can't play // Filter out file formats that the <video> tag definitely can't play
var videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv']) const videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv'])
if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb) if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb)
// Third, try to use the largest image file // Third, try to use the largest image file
var imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.jpeg', '.png']) const imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.jpeg', '.png'])
if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb) if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb)
// TODO: generate a waveform from the largest sound file // TODO: generate a waveform from the largest sound file
@@ -25,8 +25,8 @@ function torrentPoster (torrent, cb) {
} }
function getLargestFileByExtension (torrent, extensions) { function getLargestFileByExtension (torrent, extensions) {
var files = torrent.files.filter(function (file) { const files = torrent.files.filter(function (file) {
var extname = path.extname(file.name).toLowerCase() const 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
@@ -36,15 +36,15 @@ function getLargestFileByExtension (torrent, extensions) {
} }
function torrentPosterFromVideo (file, torrent, cb) { function torrentPosterFromVideo (file, torrent, cb) {
var index = torrent.files.indexOf(file) const index = torrent.files.indexOf(file)
var server = torrent.createServer(0) const server = torrent.createServer(0)
server.listen(0, onListening) server.listen(0, onListening)
function onListening () { function onListening () {
var port = server.address().port const port = server.address().port
var url = 'http://localhost:' + port + '/' + index const url = 'http://localhost:' + port + '/' + index
var video = document.createElement('video') const video = document.createElement('video')
video.addEventListener('canplay', onCanPlay) video.addEventListener('canplay', onCanPlay)
video.volume = 0 video.volume = 0
@@ -61,7 +61,7 @@ function torrentPosterFromVideo (file, torrent, cb) {
function onSeeked () { function onSeeked () {
video.removeEventListener('seeked', onSeeked) video.removeEventListener('seeked', onSeeked)
var buf = captureVideoFrame(video) const buf = captureFrame(video)
// unload video element // unload video element
video.pause() video.pause()
@@ -78,6 +78,6 @@ function torrentPosterFromVideo (file, torrent, cb) {
} }
function torrentPosterFromImage (file, torrent, cb) { function torrentPosterFromImage (file, torrent, cb) {
var extname = path.extname(file.name) const extname = path.extname(file.name)
file.getBuffer((err, buf) => cb(err, buf, extname)) file.getBuffer((err, buf) => cb(err, buf, extname))
} }

View File

@@ -2,12 +2,12 @@ module.exports = {
getPosterPath, getPosterPath,
getTorrentPath, getTorrentPath,
getByKey, getByKey,
getTorrentID, getTorrentId,
getFileOrFolder getFileOrFolder
} }
var path = require('path') const path = require('path')
var config = require('../../config') const config = require('../../config')
// Expects a torrentSummary // Expects a torrentSummary
// Returns an absolute path to the torrent file, or null if unavailable // Returns an absolute path to the torrent file, or null if unavailable
@@ -20,7 +20,7 @@ function getTorrentPath (torrentSummary) {
// Returns an absolute path to the poster image, or null if unavailable // Returns an absolute path to the poster image, or null if unavailable
function getPosterPath (torrentSummary) { function getPosterPath (torrentSummary) {
if (!torrentSummary || !torrentSummary.posterFileName) return null if (!torrentSummary || !torrentSummary.posterFileName) return null
var posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName) const posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName)
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron): // Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
// Backslashes in URLS in CSS cause bizarre string encoding issues // Backslashes in URLS in CSS cause bizarre string encoding issues
return posterPath.replace(/\\/g, '/') return posterPath.replace(/\\/g, '/')
@@ -28,8 +28,8 @@ function getPosterPath (torrentSummary) {
// Expects a torrentSummary // Expects a torrentSummary
// Returns a torrentID: filename, magnet URI, or infohash // Returns a torrentID: filename, magnet URI, or infohash
function getTorrentID (torrentSummary) { function getTorrentId (torrentSummary) {
var s = torrentSummary const s = torrentSummary
if (s.torrentFileName) { // Load torrent file from disk if (s.torrentFileName) { // Load torrent file from disk
return getTorrentPath(s) return getTorrentPath(s)
} else { // Load torrent from DHT } else { // Load torrent from DHT
@@ -51,7 +51,8 @@ function getByKey (state, torrentKey) {
// TODO: make this assumption explicit, enforce it in the `create-torrent` // TODO: make this assumption explicit, enforce it in the `create-torrent`
// module. Store root folder explicitly to avoid hacky path processing below. // module. Store root folder explicitly to avoid hacky path processing below.
function getFileOrFolder (torrentSummary) { function getFileOrFolder (torrentSummary) {
var ts = torrentSummary const ts = torrentSummary
if (!ts.path || !ts.files || ts.files.length === 0) return null if (!ts.path || !ts.files || ts.files.length === 0) return null
return path.join(ts.path, ts.files[0].path.split('/')[0]) const dirname = ts.files[0].path.split(path.sep)[0]
return path.join(ts.path, dirname)
} }

View File

@@ -1,71 +1,124 @@
/**
* Perf optimization: Hook into require() to modify how certain modules load:
*
* - `inline-style-prefixer` (used by `material-ui`) takes ~40ms. It is not
* actually used because auto-prefixing is disabled with
* `darkBaseTheme.userAgent = false`. Return a fake object.
*/
let Module = require('module')
const _require = Module.prototype.require
Module.prototype.require = function (id) {
if (id === 'inline-style-prefixer') return {}
return _require.apply(this, arguments)
}
console.time('init') console.time('init')
const crashReporter = require('../crash-reporter') const crashReporter = require('../crash-reporter')
crashReporter.init() crashReporter.init()
// Perf optimization: Start asynchronously read on config file before all the
// blocking require() calls below.
const State = require('./lib/state')
State.load(onState)
const createGetter = require('fn-getter')
const debounce = require('debounce')
const dragDrop = require('drag-drop') const dragDrop = require('drag-drop')
const electron = require('electron') const electron = require('electron')
const fs = require('fs')
const React = require('react') const React = require('react')
const ReactDOM = require('react-dom') const ReactDOM = require('react-dom')
const fs = require('fs')
const config = require('../config') const config = require('../config')
const App = require('./views/app')
const telemetry = require('./lib/telemetry') const telemetry = require('./lib/telemetry')
const sound = require('./lib/sound') const sound = require('./lib/sound')
const State = require('./lib/state')
const TorrentPlayer = require('./lib/torrent-player') const TorrentPlayer = require('./lib/torrent-player')
const MediaController = require('./controllers/media-controller') const Plugins = require('./plugins')
const UpdateController = require('./controllers/update-controller') const plugins = new Plugins()
const PrefsController = require('./controllers/prefs-controller')
const PlaybackController = require('./controllers/playback-controller') // Perf optimization: Needed immediately, so do not lazy load it below
const SubtitlesController = require('./controllers/subtitles-controller')
const TorrentListController = require('./controllers/torrent-list-controller') const TorrentListController = require('./controllers/torrent-list-controller')
const TorrentController = require('./controllers/torrent-controller')
// Required by Material UI -- adds `onTouchTap` event
require('react-tap-event-plugin')()
const App = require('./pages/app')
// 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,
// and this IPC channel receives from and sends messages to the main process
const ipcRenderer = electron.ipcRenderer
// Yo-yo pattern: state object lives here and percolates down thru all the views. // Yo-yo pattern: state object lives here and percolates down thru all the views.
// Events come back up from the views via dispatch(...) // Events come back up from the views via dispatch(...)
require('./lib/dispatcher').setDispatch(dispatch) require('./lib/dispatcher').setDispatch(dispatch)
// From dispatch(...), events are sent to one of the controllers // From dispatch(...), events are sent to one of the controllers
var controllers = null let controllers = null
// This dependency is the slowest-loading, so we lazy load it // This dependency is the slowest-loading, so we lazy load it
var Cast = null let Cast = null
// 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,
// and this IPC channel receives from and sends messages to the main process
var ipcRenderer = electron.ipcRenderer
// All state lives in state.js. `state.saved` is read from and written to a file. // All state lives in state.js. `state.saved` is read from and written to a file.
// All other state is ephemeral. First we load state.saved then initialize the app. // All other state is ephemeral. First we load state.saved then initialize the app.
var state let state
// Root React component // Root React component
var app let app
State.load(onState)
// Called once when the application loads. (Not once per window.) // Called once when the application loads. (Not once per window.)
// Connects to the torrent networks, sets up the UI and OS integrations like // Connects to the torrent networks, sets up the UI and OS integrations like
// the dock icon and drag+drop. // the dock icon and drag+drop.
function onState (err, _state) { function onState (err, _state) {
if (err) return onError(err) if (err) return onError(err)
state = window.state = _state // Make available for easier debugging
// Make available for easier debugging
state = window.state = _state
window.dispatch = dispatch
telemetry.init(state)
// Log uncaught JS errors
window.addEventListener(
'error', (e) => telemetry.logUncaughtError('window', e), true /* capture */
)
// Create controllers // Create controllers
controllers = { controllers = {
media: new MediaController(state), media: createGetter(() => {
update: new UpdateController(state), const MediaController = require('./controllers/media-controller')
prefs: new PrefsController(state, config), return new MediaController(state)
playback: new PlaybackController(state, config, update), }),
subtitles: new SubtitlesController(state), playback: createGetter(() => {
torrentList: new TorrentListController(state), const PlaybackController = require('./controllers/playback-controller')
torrent: new TorrentController(state) return new PlaybackController(state, config, update)
}),
prefs: createGetter(() => {
const PrefsController = require('./controllers/prefs-controller')
return new PrefsController(state, config)
}),
subtitles: createGetter(() => {
const SubtitlesController = require('./controllers/subtitles-controller')
return new SubtitlesController(state)
}),
torrent: createGetter(() => {
const TorrentController = require('./controllers/torrent-controller')
return new TorrentController(state)
}),
torrentList: createGetter(() => {
return new TorrentListController(state)
}),
update: createGetter(() => {
const UpdateController = require('./controllers/update-controller')
return new UpdateController(state)
})
} }
plugins.init({dispatch, state})
// Add first page to location history // Add first page to location history
state.location.go({ state.location.go({
url: 'home', url: 'home',
@@ -78,23 +131,18 @@ function onState (err, _state) {
// Restart everything we were torrenting last time the app ran // Restart everything we were torrenting last time the app ran
resumeTorrents() resumeTorrents()
// Initialize ReactDOM
app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
// Calling update() updates the UI given the current state // Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary // Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video // a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000) setInterval(update, 1000)
app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
// Lazy-load other stuff, like the AppleTV module, later to keep startup fast
window.setTimeout(delayedInit, config.DELAYED_INIT)
// Listen for messages from the main process // Listen for messages from the main process
setupIpc() setupIpc()
// Warn if the download dir is gone, eg b/c an external drive is unplugged // Drag and drop files/text to start torrenting or seeding
checkDownloadPath()
// OS integrations:
// ...drag and drop files/text to start torrenting or seeding
dragDrop('body', { dragDrop('body', {
onDrop: onOpen, onDrop: onOpen,
onDropText: onOpen onDropText: onOpen
@@ -103,27 +151,49 @@ function onState (err, _state) {
// ...same thing if you paste a torrent // ...same thing if you paste a torrent
document.addEventListener('paste', onPaste) document.addEventListener('paste', onPaste)
const debouncedFullscreenToggle = debounce(function () {
dispatch('toggleFullScreen')
}, 1000, true)
document.addEventListener('wheel', function (event) {
// ctrlKey detects pinch to zoom, http://crbug.com/289887
if (event.ctrlKey) {
event.preventDefault()
debouncedFullscreenToggle()
}
})
// ...focus and blur. Needed to show correct dock icon text ('badge') in OSX // ...focus and blur. Needed to show correct dock icon text ('badge') in OSX
window.addEventListener('focus', onFocus) window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur) window.addEventListener('blur', onBlur)
// ...window visibility state. if (electron.remote.getCurrentWindow().isVisible()) {
document.addEventListener('webkitvisibilitychange', onVisibilityChange) sound.play('STARTUP')
}
// Log uncaught JS errors // To keep app startup fast, some code is delayed.
window.addEventListener('error', window.setTimeout(delayedInit, config.DELAYED_INIT)
(e) => telemetry.logUncaughtError('window', e.error || e.target), true)
// Done! Ideally we want to get here < 500ms after the user clicks the app // Done! Ideally we want to get here < 500ms after the user clicks the app
sound.play('STARTUP')
console.timeEnd('init') console.timeEnd('init')
} }
// Runs a few seconds after the app loads, to avoid slowing down startup time // Runs a few seconds after the app loads, to avoid slowing down startup time
function delayedInit () { function delayedInit () {
telemetry.send(state)
// Send telemetry data every 12 hours, for users who keep the app running
// for extended periods of time
setInterval(() => telemetry.send(state), 12 * 3600 * 1000)
// Warn if the download dir is gone, eg b/c an external drive is unplugged
checkDownloadPath()
// ...window visibility state.
document.addEventListener('webkitvisibilitychange', onVisibilityChange)
onVisibilityChange()
lazyLoadCast() lazyLoadCast()
sound.preload()
telemetry.init(state)
} }
// Lazily loads Chromecast and Airplay support // Lazily loads Chromecast and Airplay support
@@ -141,7 +211,7 @@ function lazyLoadCast () {
// 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller // 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller
// 4. controller - the controller handles the event, changing the state object // 4. controller - the controller handles the event, changing the state object
function update () { function update () {
controllers.playback.showOrHidePlayerControls() controllers.playback().showOrHidePlayerControls()
app.setState(state) app.setState(state)
updateElectron() updateElectron()
} }
@@ -153,7 +223,7 @@ function updateElectron () {
state.prev.title = state.window.title state.prev.title = state.window.title
ipcRenderer.send('setTitle', state.window.title) ipcRenderer.send('setTitle', state.window.title)
} }
if (state.dock.progress !== state.prev.progress) { if (state.dock.progress.toFixed(2) !== state.prev.progress.toFixed(2)) {
state.prev.progress = state.dock.progress state.prev.progress = state.dock.progress
ipcRenderer.send('setProgress', state.dock.progress) ipcRenderer.send('setProgress', state.dock.progress)
} }
@@ -169,43 +239,58 @@ const dispatchHandlers = {
'openFiles': () => ipcRenderer.send('openFiles'), /* shows the open file dialog */ 'openFiles': () => ipcRenderer.send('openFiles'), /* shows the open file dialog */
'openTorrentAddress': () => { state.modal = { id: 'open-torrent-address-modal' } }, 'openTorrentAddress': () => { state.modal = { id: 'open-torrent-address-modal' } },
'addTorrent': (torrentId) => controllers.torrentList.addTorrent(torrentId), 'addTorrent': (torrentId) => controllers.torrentList().addTorrent(torrentId),
'showCreateTorrent': (paths) => controllers.torrentList.showCreateTorrent(paths), 'showCreateTorrent': (paths) => controllers.torrentList().showCreateTorrent(paths),
'toggleCreateTorrentAdvanced': () => controllers.torrentList.toggleCreateTorrentAdvanced(), 'createTorrent': (options) => controllers.torrentList().createTorrent(options),
'createTorrent': (options) => controllers.torrentList.createTorrent(options), 'toggleTorrent': (infoHash) => controllers.torrentList().toggleTorrent(infoHash),
'toggleTorrent': (infoHash) => controllers.torrentList.toggleTorrent(infoHash), 'pauseAllTorrents': () => controllers.torrentList().pauseAllTorrents(),
'toggleTorrentFile': (infoHash, index) => controllers.torrentList.toggleTorrentFile(infoHash, index), 'resumeAllTorrents': () => controllers.torrentList().resumeAllTorrents(),
'confirmDeleteTorrent': (infoHash, deleteData) => controllers.torrentList.confirmDeleteTorrent(infoHash, deleteData), 'toggleTorrentFile': (infoHash, index) =>
'deleteTorrent': (infoHash, deleteData) => controllers.torrentList.deleteTorrent(infoHash, deleteData), controllers.torrentList().toggleTorrentFile(infoHash, index),
'toggleSelectTorrent': (infoHash) => controllers.torrentList.toggleSelectTorrent(infoHash), 'confirmDeleteTorrent': (infoHash, deleteData) =>
'openTorrentContextMenu': (infoHash) => controllers.torrentList.openTorrentContextMenu(infoHash), controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData),
'startTorrentingSummary': (torrentKey) => controllers.torrentList.startTorrentingSummary(torrentKey), 'deleteTorrent': (infoHash, deleteData) =>
controllers.torrentList().deleteTorrent(infoHash, deleteData),
'toggleSelectTorrent': (infoHash) =>
controllers.torrentList().toggleSelectTorrent(infoHash),
'openTorrentContextMenu': (infoHash) =>
controllers.torrentList().openTorrentContextMenu(infoHash),
'startTorrentingSummary': (torrentKey) =>
controllers.torrentList().startTorrentingSummary(torrentKey),
'saveTorrentFileAs': (torrentKey) =>
controllers.torrentList().saveTorrentFileAs(torrentKey),
'prioritizeTorrent': (infoHash) => controllers.torrentList().prioritizeTorrent(infoHash),
'resumePausedTorrents': () => controllers.torrentList().resumePausedTorrents(),
// Playback // Playback
'playFile': (infoHash, index) => controllers.playback.playFile(infoHash, index), 'playFile': (infoHash, index) => controllers.playback().playFile(infoHash, index),
'playPause': () => controllers.playback.playPause(), 'playPause': () => controllers.playback().playPause(),
'skip': (time) => controllers.playback.skip(time), 'nextTrack': () => controllers.playback().nextTrack(),
'skipTo': (time) => controllers.playback.skipTo(time), 'previousTrack': () => controllers.playback().previousTrack(),
'changePlaybackRate': (dir) => controllers.playback.changePlaybackRate(dir), 'skip': (time) => controllers.playback().skip(time),
'changeVolume': (delta) => controllers.playback.changeVolume(delta), 'skipTo': (time) => controllers.playback().skipTo(time),
'setVolume': (vol) => controllers.playback.setVolume(vol), 'changePlaybackRate': (dir) => controllers.playback().changePlaybackRate(dir),
'openItem': (infoHash, index) => controllers.playback.openItem(infoHash, index), 'changeVolume': (delta) => controllers.playback().changeVolume(delta),
'setVolume': (vol) => controllers.playback().setVolume(vol),
'openItem': (infoHash, index) => controllers.playback().openItem(infoHash, index),
// Subtitles // Subtitles
'openSubtitles': () => controllers.subtitles.openSubtitles(), 'openSubtitles': () => controllers.subtitles().openSubtitles(),
'selectSubtitle': (index) => controllers.subtitles.selectSubtitle(index), 'selectSubtitle': (index) => controllers.subtitles().selectSubtitle(index),
'toggleSubtitlesMenu': () => controllers.subtitles.toggleSubtitlesMenu(), 'toggleSubtitlesMenu': () => controllers.subtitles().toggleSubtitlesMenu(),
'checkForSubtitles': () => controllers.subtitles.checkForSubtitles(), 'checkForSubtitles': () => controllers.subtitles().checkForSubtitles(),
'addSubtitles': (files, autoSelect) => controllers.subtitles.addSubtitles(files, autoSelect), 'addSubtitles': (files, autoSelect) => controllers.subtitles().addSubtitles(files, autoSelect),
// Local media: <video>, <audio>, external players // Local media: <video>, <audio>, external players
'mediaStalled': () => controllers.media.mediaStalled(), 'mediaStalled': () => controllers.media().mediaStalled(),
'mediaError': (err) => controllers.media.mediaError(err), 'mediaError': (err) => controllers.media().mediaError(err),
'mediaSuccess': () => controllers.media.mediaSuccess(), 'mediaSuccess': () => controllers.media().mediaSuccess(),
'mediaTimeUpdate': () => controllers.media.mediaTimeUpdate(), 'mediaTimeUpdate': () => controllers.media().mediaTimeUpdate(),
'mediaMouseMoved': () => controllers.media.mediaMouseMoved(), 'mediaMouseMoved': () => controllers.media().mediaMouseMoved(),
'openExternalPlayer': () => controllers.media.openExternalPlayer(), 'mediaControlsMouseEnter': () => controllers.media().controlsMouseEnter(),
'externalPlayerNotFound': () => controllers.media.externalPlayerNotFound(), 'mediaControlsMouseLeave': () => controllers.media().controlsMouseLeave(),
'openExternalPlayer': () => controllers.media().openExternalPlayer(),
'externalPlayerNotFound': () => controllers.media().externalPlayerNotFound(),
// Remote casting: Chromecast, Airplay, etc // Remote casting: Chromecast, Airplay, etc
'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType), 'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType),
@@ -213,13 +298,13 @@ const dispatchHandlers = {
'stopCasting': () => lazyLoadCast().stop(), 'stopCasting': () => lazyLoadCast().stop(),
// Preferences screen // Preferences screen
'preferences': () => controllers.prefs.show(), 'preferences': () => controllers.prefs().show(),
'updatePreferences': (key, value) => controllers.prefs.update(key, value), 'updatePreferences': (key, value) => controllers.prefs().update(key, value),
'checkDownloadPath': checkDownloadPath, 'checkDownloadPath': checkDownloadPath,
// Update (check for new versions on Linux, where there's no auto updater) // Update (check for new versions on Linux, where there's no auto updater)
'updateAvailable': (version) => controllers.update.updateAvailable(version), 'updateAvailable': (version) => controllers.update().updateAvailable(version),
'skipVersion': (version) => controllers.update.skipVersion(version), 'skipVersion': (version) => controllers.update().skipVersion(version),
// Navigation between screens (back, forward, ESC, etc) // Navigation between screens (back, forward, ESC, etc)
'exitModal': () => { state.modal = null }, 'exitModal': () => { state.modal = null },
@@ -239,8 +324,8 @@ const dispatchHandlers = {
'onOpen': onOpen, 'onOpen': onOpen,
'error': onError, 'error': onError,
'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err), 'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err),
'saveState': () => State.save(state), 'stateSave': () => State.save(state),
'saveStateThrottled': () => State.saveThrottled(state), 'stateSaveImmediate': () => State.saveImmediate(state),
'update': () => {} // No-op, just trigger an update 'update': () => {} // No-op, just trigger an update
} }
@@ -251,13 +336,14 @@ function dispatch (action, ...args) {
console.log('dispatch: %s %o', action, args) console.log('dispatch: %s %o', action, args)
} }
var handler = dispatchHandlers[action] const handler = dispatchHandlers[action]
plugins.on(action, args)
if (handler) handler(...args) if (handler) handler(...args)
else console.error('Missing dispatch handler: ' + action) else console.error('Missing dispatch handler: ' + action)
// Update the virtual DOM, unless it's just a mouse move event // Update the virtual DOM, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' || if (action !== 'mediaMouseMoved' ||
controllers.playback.showOrHidePlayerControls()) { controllers.playback().showOrHidePlayerControls()) {
update() update()
} }
} }
@@ -270,11 +356,13 @@ function setupIpc () {
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args)) ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
ipcRenderer.on('fullscreenChanged', onFullscreenChanged) ipcRenderer.on('fullscreenChanged', onFullscreenChanged)
ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged)
var tc = controllers.torrent const tc = controllers.torrent()
ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args)) ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args)) ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args)) ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args))
ipcRenderer.on('wt-done', () => controllers.torrentList().resumePausedTorrents())
ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args)) ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args))
ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args)) ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args))
@@ -289,7 +377,7 @@ function setupIpc () {
ipcRenderer.send('ipcReady') ipcRenderer.send('ipcReady')
State.on('savedState', () => ipcRenderer.send('savedState')) State.on('stateSaved', () => ipcRenderer.send('stateSaved'))
} }
// Quits any modal popovers and returns to the torrent list screen // Quits any modal popovers and returns to the torrent list screen
@@ -298,7 +386,7 @@ function backToList () {
state.modal = null state.modal = null
state.location.backToFirst(function () { state.location.backToFirst(function () {
// If we were already on the torrent list, scroll to the top // If we were already on the torrent list, scroll to the top
var contentTag = document.querySelector('.content') const contentTag = document.querySelector('.content')
if (contentTag) contentTag.scrollTop = 0 if (contentTag) contentTag.scrollTop = 0
}) })
} }
@@ -324,7 +412,7 @@ function resumeTorrents () {
return torrentSummary return torrentSummary
}) })
.filter((s) => s.status !== 'paused') .filter((s) => s.status !== 'paused')
.forEach((s) => controllers.torrentList.startTorrentingSummary(s.torrentKey)) .forEach((s) => controllers.torrentList().startTorrentingSummary(s.torrentKey))
} }
// Set window dimensions to match video dimensions or fill the screen // Set window dimensions to match video dimensions or fill the screen
@@ -345,18 +433,18 @@ function setDimensions (dimensions) {
state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
// Limit window size to screen size // Limit window size to screen size
var screenWidth = window.screen.width const screenWidth = window.screen.width
var screenHeight = window.screen.height const screenHeight = window.screen.height
var aspectRatio = dimensions.width / dimensions.height const aspectRatio = dimensions.width / dimensions.height
var scaleFactor = Math.min( const scaleFactor = Math.min(
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.max( const width = Math.max(
Math.floor(dimensions.width * scaleFactor), Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH config.WINDOW_MIN_WIDTH
) )
var height = Math.max( const height = Math.max(
Math.floor(dimensions.height * scaleFactor), Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT config.WINDOW_MIN_HEIGHT
) )
@@ -371,22 +459,22 @@ function setDimensions (dimensions) {
function onOpen (files) { function onOpen (files) {
if (!Array.isArray(files)) files = [ files ] if (!Array.isArray(files)) files = [ files ]
var url = state.location.url() const url = state.location.url()
var allTorrents = files.every(TorrentPlayer.isTorrent) const allTorrents = files.every(TorrentPlayer.isTorrent)
var allSubtitles = files.every(controllers.subtitles.isSubtitle) const allSubtitles = files.every(controllers.subtitles().isSubtitle)
if (allTorrents) { if (allTorrents) {
// Drop torrents onto the app: go to home screen, add torrents, no matter what // Drop torrents onto the app: go to home screen, add torrents, no matter what
dispatch('backToList') dispatch('backToList')
// All .torrent files? Add them. // All .torrent files? Add them.
files.forEach((file) => controllers.torrentList.addTorrent(file)) files.forEach((file) => controllers.torrentList().addTorrent(file))
} else if (url === 'player' && allSubtitles) { } else if (url === 'player' && allSubtitles) {
// Drop subtitles onto a playing video: add subtitles // Drop subtitles onto a playing video: add subtitles
controllers.subtitles.addSubtitles(files, true) controllers.subtitles().addSubtitles(files, true)
} else if (url === 'home') { } else if (url === 'home') {
// Drop files onto home screen: show Create Torrent // Drop files onto home screen: show Create Torrent
state.modal = null state.modal = null
controllers.torrentList.showCreateTorrent(files) controllers.torrentList().showCreateTorrent(files)
} else { } else {
// Drop files onto any other screen: show error // Drop files onto any other screen: show error
return onError('Please go back to the torrent list before creating a new torrent.') return onError('Please go back to the torrent list before creating a new torrent.')
@@ -406,15 +494,11 @@ function onError (err) {
update() update()
} }
function onPaste (e) { const editableHtmlTags = new Set(['input', 'textarea'])
if (e.target.tagName.toLowerCase() === 'input') return
var torrentIds = electron.clipboard.readText().split('\n') function onPaste (e) {
torrentIds.forEach(function (torrentId) { if (editableHtmlTags.has(e.target.tagName.toLowerCase())) return
torrentId = torrentId.trim() controllers.torrentList().addTorrent(electron.clipboard.readText())
if (torrentId.length === 0) return
controllers.torrentList.addTorrent(torrentId)
})
update() update()
} }
@@ -444,6 +528,13 @@ function onFullscreenChanged (e, isFullScreen) {
update() update()
} }
function onWindowBoundsChanged (e, newBounds) {
if (state.location.url() !== 'player') {
state.saved.bounds = newBounds
dispatch('stateSave')
}
}
function checkDownloadPath () { function checkDownloadPath () {
fs.stat(state.saved.prefs.downloadPath, function (err, stat) { fs.stat(state.saved.prefs.downloadPath, function (err, stat) {
if (err) { if (err) {

130
src/renderer/pages/app.js Normal file
View File

@@ -0,0 +1,130 @@
const colors = require('material-ui/styles/colors')
const createGetter = require('fn-getter')
const React = require('react')
const darkBaseTheme = require('material-ui/styles/baseThemes/darkBaseTheme').default
const getMuiTheme = require('material-ui/styles/getMuiTheme').default
const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default
const Header = require('../components/header')
// Perf optimization: Needed immediately, so do not lazy load it below
const TorrentListPage = require('./torrent-list-page')
const Views = {
'home': createGetter(() => TorrentListPage),
'player': createGetter(() => require('./player-page')),
'create-torrent': createGetter(() => require('./create-torrent-page')),
'preferences': createGetter(() => require('./preferences-page'))
}
const Modals = {
'open-torrent-address-modal': createGetter(
() => require('../components/open-torrent-address-modal')
),
'remove-torrent-modal': createGetter(() => require('../components/remove-torrent-modal')),
'update-available-modal': createGetter(() => require('../components/update-available-modal')),
'unsupported-media-modal': createGetter(() => require('../components/unsupported-media-modal'))
}
const fontFamily = process.platform === 'win32'
? '"Segoe UI", sans-serif'
: 'BlinkMacSystemFont, "Helvetica Neue", Helvetica, sans-serif'
darkBaseTheme.fontFamily = fontFamily
darkBaseTheme.userAgent = false
darkBaseTheme.palette.primary1Color = colors.grey50
darkBaseTheme.palette.primary2Color = colors.grey50
darkBaseTheme.palette.primary3Color = colors.grey600
darkBaseTheme.palette.accent1Color = colors.redA200
darkBaseTheme.palette.accent2Color = colors.redA400
darkBaseTheme.palette.accent3Color = colors.redA100
let darkMuiTheme
let lightMuiTheme
class App extends React.Component {
render () {
const state = this.props.state
// Hide player controls while playing video, if the mouse stays still for a while
// Never hide the controls when:
// * The mouse is over the controls or we're scrubbing (see CSS)
// * The video is paused
// * The video is playing remotely on Chromecast or Airplay
const hideControls = state.shouldHidePlayerControls()
const cls = [
'view-' + state.location.url(), /* e.g. view-home, view-player */
'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */
]
if (state.window.isFullScreen) cls.push('is-fullscreen')
if (state.window.isFocused) cls.push('is-focused')
if (hideControls) cls.push('hide-video-controls')
if (!darkMuiTheme) {
darkMuiTheme = getMuiTheme(darkBaseTheme)
}
return (
<MuiThemeProvider muiTheme={darkMuiTheme}>
<div className={'app ' + cls.join(' ')}>
<Header state={state} />
{this.getErrorPopover()}
<div key='content' className='content'>{this.getView()}</div>
{this.getModal()}
</div>
</MuiThemeProvider>
)
}
getErrorPopover () {
const state = this.props.state
const now = new Date().getTime()
const recentErrors = state.errors.filter((x) => now - x.time < 5000)
const hasErrors = recentErrors.length > 0
const errorElems = recentErrors.map(function (error, i) {
return (<div key={i} className='error'>{error.message}</div>)
})
return (
<div key='errors'
className={'error-popover ' + (hasErrors ? 'visible' : 'hidden')}>
<div key='title' className='title'>Error</div>
{errorElems}
</div>
)
}
getModal () {
const state = this.props.state
if (!state.modal) return
if (!lightMuiTheme) {
const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default
lightBaseTheme.fontFamily = fontFamily
lightBaseTheme.userAgent = false
lightMuiTheme = getMuiTheme(lightBaseTheme)
}
const ModalContents = Modals[state.modal.id]()
return (
<MuiThemeProvider muiTheme={lightMuiTheme}>
<div key='modal' className='modal'>
<div key='modal-background' className='modal-background' />
<div key='modal-content' className='modal-content'>
<ModalContents state={state} />
</div>
</div>
</MuiThemeProvider>
)
}
getView () {
const state = this.props.state
const View = Views[state.location.url()]()
return (<View state={state} />)
}
}
module.exports = App

View File

@@ -0,0 +1,216 @@
const createTorrent = require('create-torrent')
const path = require('path')
const prettyBytes = require('prettier-bytes')
const React = require('react')
const {dispatch, dispatcher} = require('../lib/dispatcher')
const FlatButton = require('material-ui/FlatButton').default
const RaisedButton = require('material-ui/RaisedButton').default
const TextField = require('material-ui/TextField').default
const Checkbox = require('material-ui/Checkbox').default
const CreateTorrentErrorPage = require('../components/create-torrent-error-page')
const Heading = require('../components/heading')
const ShowMore = require('../components/show-more')
// Shows a basic UI to create a torrent, from an already-selected file or folder.
// Includes a "Show Advanced..." button and more advanced UI.
class CreateTorrentPage extends React.Component {
constructor (props) {
super(props)
const state = this.props.state
const info = state.location.current()
// First, extract the base folder that the files are all in
let pathPrefix = info.folderPath
if (!pathPrefix) {
pathPrefix = info.files.map((x) => x.path).reduce(findCommonPrefix)
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
}
// Then, exclude .DS_Store and other dotfiles
const files = info.files
.filter((f) => !containsDots(f.path, pathPrefix))
.map((f) => ({name: f.name, path: f.path, size: f.size}))
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
// 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.
let defaultName, basePath
if (files.length === 1) {
// Single file torrent: /a/b/foo.jpg -> torrent name 'foo.jpg', path '/a/b'
defaultName = files[0].name
basePath = pathPrefix
} else {
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name 'b', path '/a'
defaultName = path.basename(pathPrefix)
basePath = path.dirname(pathPrefix)
}
// Default trackers
const trackers = createTorrent.announceList.join('\n')
this.state = {
comment: '',
isPrivate: false,
pathPrefix,
basePath,
defaultName,
files,
trackers
}
// Create React event handlers only once
this.setIsPrivate = (_, isPrivate) => this.setState({isPrivate})
this.setComment = (_, comment) => this.setState({comment})
this.setTrackers = (_, trackers) => this.setState({trackers})
this.handleSubmit = handleSubmit.bind(this)
}
render () {
const files = this.state.files
// Sanity check: show the number of files and total size
const numFiles = files.length
const totalBytes = files
.map((f) => f.size)
.reduce((a, b) => a + b, 0)
const torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
return (
<div className='create-torrent'>
<Heading level={1}>Create torrent {this.state.defaultName}</Heading>
<div className='torrent-info'>{torrentInfo}</div>
<div className='torrent-attribute'>
<label>Path:</label>
<div>{this.state.pathPrefix}</div>
</div>
<ShowMore
style={{
marginBottom: 10
}}
hideLabel='Hide advanced settings...'
showLabel='Show advanced settings...'>
{this.renderAdvanced()}
</ShowMore>
<div className='float-right'>
<FlatButton
className='control cancel'
label='Cancel'
style={{
marginRight: 10
}}
onClick={dispatcher('cancel')} />
<RaisedButton
className='control create-torrent-button'
label='Create Torrent'
primary
onClick={this.handleSubmit} />
</div>
</div>
)
}
// Renders everything after clicking Show Advanced...:
// * Is Private? (private torrents, not announced to DHT)
// * Announce list (trackers)
// * Comment
renderAdvanced () {
// Create file list
const maxFileElems = 100
const files = this.state.files
const fileElems = files.slice(0, maxFileElems).map((file, i) => {
const relativePath = path.relative(this.state.pathPrefix, file.path)
return (<div key={i}>{relativePath}</div>)
})
if (files.length > maxFileElems) {
fileElems.push(<div key='more'>+ {files.length - maxFileElems} more</div>)
}
// Align the text fields
const textFieldStyle = { width: '' }
const textareaStyle = { margin: 0 }
return (
<div key='advanced' className='create-torrent-advanced'>
<div key='private' className='torrent-attribute'>
<label>Private:</label>
<Checkbox
className='torrent-is-private control'
style={{display: ''}}
checked={this.state.isPrivate}
onCheck={this.setIsPrivate} />
</div>
<div key='trackers' className='torrent-attribute'>
<label>Trackers:</label>
<TextField
className='torrent-trackers control'
style={textFieldStyle}
textareaStyle={textareaStyle}
multiLine
rows={2}
rowsMax={10}
value={this.state.trackers}
onChange={this.setTrackers} />
</div>
<div key='comment' className='torrent-attribute'>
<label>Comment:</label>
<TextField
className='torrent-comment control'
style={textFieldStyle}
textareaStyle={textareaStyle}
hintText='Optionally describe your torrent...'
multiLine
rows={2}
rowsMax={10}
value={this.state.comment}
onChange={this.setComment} />
</div>
<div key='files' className='torrent-attribute'>
<label>Files:</label>
<div>{fileElems}</div>
</div>
</div>
)
}
}
function handleSubmit () {
const announceList = this.state.trackers
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
const 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.
name: this.state.defaultName,
path: this.state.basePath,
files: this.state.files,
announce: announceList,
private: this.state.isPrivate,
comment: this.state.comment.trim()
}
dispatch('createTorrent', options)
}
// Finds the longest common prefix
function findCommonPrefix (a, b) {
let i
for (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)
}
function containsDots (path, pathPrefix) {
const suffix = path.substring(pathPrefix.length).replace(/\\/g, '/')
return ('/' + suffix).includes('/.')
}
module.exports = CreateTorrentPage

View File

@@ -2,28 +2,39 @@ const React = require('react')
const Bitfield = require('bitfield') const Bitfield = require('bitfield')
const prettyBytes = require('prettier-bytes') const prettyBytes = require('prettier-bytes')
const zeroFill = require('zero-fill') const zeroFill = require('zero-fill')
const path = require('path')
const TorrentSummary = require('../lib/torrent-summary') const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const {dispatch, dispatcher} = require('../lib/dispatcher') const {dispatch, dispatcher} = require('../lib/dispatcher')
const config = require('../../config')
// Shows a streaming video player. Standard features + Chromecast + Airplay // Shows a streaming video player. Standard features + Chromecast + Airplay
module.exports = class Player extends React.Component { module.exports = class Player extends React.Component {
render () { render () {
// Show the video as large as will fit in the window, play immediately // Show the video as large as will fit in the window, play immediately
// If the video is on Chromecast or Airplay, show a title screen instead // If the video is on Chromecast or Airplay, show a title screen instead
var state = this.props.state const state = this.props.state
var showVideo = state.playing.location === 'local' const showVideo = state.playing.location === 'local'
const showControls = state.playing.location !== 'external'
return ( return (
<div <div
className='player' className='player'
onWheel={handleVolumeWheel} onWheel={handleVolumeWheel}
onMouseMove={dispatcher('mediaMouseMoved')}> onMouseMove={dispatcher('mediaMouseMoved')}>
{showVideo ? renderMedia(state) : renderCastScreen(state)} {showVideo ? renderMedia(state) : renderCastScreen(state)}
{renderPlayerControls(state)} {showControls ? renderPlayerControls(state) : null}
</div> </div>
) )
} }
onComponentWillUnmount () {
// Unload the media element so that Chromium stops trying to fetch data
const tag = document.querySelector('audio,video')
if (!tag) return
tag.pause()
tag.src = ''
tag.load()
}
} }
// Handles volume change by wheel // Handles volume change by wheel
@@ -37,7 +48,7 @@ function renderMedia (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
// Get the <video> or <audio> tag // Get the <video> or <audio> tag
var mediaElement = document.querySelector(state.playing.type) const mediaElement = document.querySelector(state.playing.type)
if (mediaElement !== null) { if (mediaElement !== null) {
if (state.playing.isPaused && !mediaElement.paused) { if (state.playing.isPaused && !mediaElement.paused) {
mediaElement.pause() mediaElement.pause()
@@ -65,20 +76,20 @@ function renderMedia (state) {
} }
// Switch to the newly added subtitle track, if available // Switch to the newly added subtitle track, if available
var tracks = mediaElement.textTracks || [] const tracks = mediaElement.textTracks || []
for (var j = 0; j < tracks.length; j++) { for (let j = 0; j < tracks.length; j++) {
var isSelectedTrack = j === state.playing.subtitles.selectedIndex const isSelectedTrack = j === state.playing.subtitles.selectedIndex
tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden' tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden'
} }
// Save video position // Save video position
var file = state.getPlayingFileSummary() const file = state.getPlayingFileSummary()
file.currentTime = state.playing.currentTime = mediaElement.currentTime file.currentTime = state.playing.currentTime = mediaElement.currentTime
file.duration = state.playing.duration = mediaElement.duration file.duration = state.playing.duration = mediaElement.duration
// Save selected subtitle // Save selected subtitle
if (state.playing.subtitles.selectedIndex !== -1) { if (state.playing.subtitles.selectedIndex !== -1) {
var index = state.playing.subtitles.selectedIndex const index = state.playing.subtitles.selectedIndex
file.selectedSubtitle = state.playing.subtitles.tracks[index].filePath file.selectedSubtitle = state.playing.subtitles.tracks[index].filePath
} else if (file.selectedSubtitle != null) { } else if (file.selectedSubtitle != null) {
delete file.selectedSubtitle delete file.selectedSubtitle
@@ -88,11 +99,11 @@ function renderMedia (state) {
} }
// Add subtitles to the <video> tag // Add subtitles to the <video> tag
var trackTags = [] const trackTags = []
if (state.playing.subtitles.selectedIndex >= 0) { if (state.playing.subtitles.selectedIndex >= 0) {
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) { for (let i = 0; i < state.playing.subtitles.tracks.length; i++) {
var track = state.playing.subtitles.tracks[i] const track = state.playing.subtitles.tracks[i]
var isSelected = state.playing.subtitles.selectedIndex === i const isSelected = state.playing.subtitles.selectedIndex === i
trackTags.push( trackTags.push(
<track <track
key={i} key={i}
@@ -105,10 +116,10 @@ function renderMedia (state) {
} }
// Create the <audio> or <video> tag // Create the <audio> or <video> tag
var MediaTagName = state.playing.type const MediaTagName = state.playing.type
var mediaTag = ( const mediaTag = (
<MediaTagName <MediaTagName
src={state.server.localURL} src={Playlist.getCurrentLocalURL(state)}
onDoubleClick={dispatcher('toggleFullScreen')} onDoubleClick={dispatcher('toggleFullScreen')}
onLoadedMetadata={onLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onEnded={onEnded} onEnded={onEnded}
@@ -135,21 +146,25 @@ function renderMedia (state) {
// As soon as we know the video dimensions, resize the window // As soon as we know the video dimensions, resize the window
function onLoadedMetadata (e) { function onLoadedMetadata (e) {
if (state.playing.type !== 'video') return if (state.playing.type !== 'video') return
var video = e.target const video = e.target
var dimensions = { const dimensions = {
width: video.videoWidth, width: video.videoWidth,
height: video.videoHeight height: video.videoHeight
} }
dispatch('setDimensions', dimensions) dispatch('setDimensions', dimensions)
} }
// When the video completes, pause the video instead of looping
function onEnded (e) { function onEnded (e) {
state.playing.isPaused = true if (Playlist.hasNext(state)) {
dispatch('nextTrack')
} else {
// When the last video completes, pause the video instead of looping
state.playing.isPaused = true
}
} }
function onCanPlay (e) { function onCanPlay (e) {
var elem = e.target const elem = e.target
if (state.playing.type === 'video' && if (state.playing.type === 'video' &&
elem.webkitVideoDecodedByteCount === 0) { elem.webkitVideoDecodedByteCount === 0) {
dispatch('mediaError', 'Video codec unsupported') dispatch('mediaError', 'Video codec unsupported')
@@ -163,15 +178,15 @@ function renderMedia (state) {
} }
function renderOverlay (state) { function renderOverlay (state) {
var elems = [] const elems = []
var audioMetadataElem = renderAudioMetadata(state) const audioMetadataElem = renderAudioMetadata(state)
var spinnerElem = renderLoadingSpinner(state) const spinnerElem = renderLoadingSpinner(state)
if (audioMetadataElem) elems.push(audioMetadataElem) if (audioMetadataElem) elems.push(audioMetadataElem)
if (spinnerElem) elems.push(spinnerElem) if (spinnerElem) elems.push(spinnerElem)
// Video fills the window, centered with black bars if necessary // Video fills the window, centered with black bars if necessary
// Audio gets a static poster image and a summary of the file metadata. // Audio gets a static poster image and a summary of the file metadata.
var style let style
if (state.playing.type === 'audio') { if (state.playing.type === 'audio') {
style = { backgroundImage: cssBackgroundImagePoster(state) } style = { backgroundImage: cssBackgroundImagePoster(state) }
} else if (elems.length !== 0) { } else if (elems.length !== 0) {
@@ -189,27 +204,27 @@ function renderOverlay (state) {
} }
function renderAudioMetadata (state) { function renderAudioMetadata (state) {
var fileSummary = state.getPlayingFileSummary() const fileSummary = state.getPlayingFileSummary()
if (!fileSummary.audioInfo) return if (!fileSummary.audioInfo) return
var info = fileSummary.audioInfo const info = fileSummary.audioInfo
// Get audio track info // Get audio track info
var title = info.title let title = info.title
if (!title) { if (!title) {
title = fileSummary.name title = fileSummary.name
} }
var artist = info.artist && info.artist[0] let artist = info.artist && info.artist[0]
var album = info.album let album = info.album
if (album && info.year && !album.includes(info.year)) { if (album && info.year && !album.includes(info.year)) {
album += ' (' + info.year + ')' album += ' (' + info.year + ')'
} }
var track let track
if (info.track && info.track.no && info.track.of) { if (info.track && info.track.no && info.track.of) {
track = info.track.no + ' of ' + info.track.of track = info.track.no + ' of ' + info.track.of
} }
// Show a small info box in the middle of the screen with title/album/etc // Show a small info box in the middle of the screen with title/album/etc
var elems = [] const elems = []
if (artist) { if (artist) {
elems.push(( elems.push((
<div key='artist' className='audio-artist'> <div key='artist' className='audio-artist'>
@@ -233,7 +248,7 @@ function renderAudioMetadata (state) {
} }
// Align the title with the other info, if available. Otherwise, center title // Align the title with the other info, if available. Otherwise, center title
var emptyLabel = (<label />) const emptyLabel = (<label />)
elems.unshift(( elems.unshift((
<div key='title' className='audio-title'> <div key='title' className='audio-title'>
{elems.length ? emptyLabel : undefined}{title} {elems.length ? emptyLabel : undefined}{title}
@@ -245,14 +260,14 @@ function renderAudioMetadata (state) {
function renderLoadingSpinner (state) { function renderLoadingSpinner (state) {
if (state.playing.isPaused) return if (state.playing.isPaused) return
var isProbablyStalled = state.playing.isStalled || const isProbablyStalled = state.playing.isStalled ||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000) (new Date().getTime() - state.playing.lastTimeUpdate > 2000)
if (!isProbablyStalled) return if (!isProbablyStalled) return
var prog = state.getPlayingTorrentSummary().progress || {} const prog = state.getPlayingTorrentSummary().progress || {}
var fileProgress = 0 let fileProgress = 0
if (prog.files) { if (prog.files) {
var file = prog.files[state.playing.fileIndex] const file = prog.files[state.playing.fileIndex]
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces) fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
} }
@@ -260,16 +275,16 @@ function renderLoadingSpinner (state) {
<div key='loading' className='media-stalled'> <div key='loading' className='media-stalled'>
<div key='loading-spinner' className='loading-spinner'>&nbsp;</div> <div key='loading-spinner' className='loading-spinner'>&nbsp;</div>
<div key='loading-progress' className='loading-status ellipsis'> <div key='loading-progress' className='loading-status ellipsis'>
<span className='progress'>{fileProgress}%</span> downloaded, <span className='progress'>{fileProgress}%</span> downloaded
<span> {prettyBytes(prog.downloadSpeed || 0)}/s</span> <span> {prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span> {prettyBytes(prog.uploadSpeed || 0)}/s</span> <span> {prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div> </div>
</div> </div>
) )
} }
function renderCastScreen (state) { function renderCastScreen (state) {
var castIcon, castType, isCast let castIcon, castType, isCast
if (state.playing.location.startsWith('chromecast')) { if (state.playing.location.startsWith('chromecast')) {
castIcon = 'cast_connected' castIcon = 'cast_connected'
castType = 'Chromecast' castType = 'Chromecast'
@@ -283,11 +298,8 @@ function renderCastScreen (state) {
castType = 'DLNA' castType = 'DLNA'
isCast = true isCast = true
} else if (state.playing.location === 'external') { } else if (state.playing.location === 'external') {
// TODO: get the player name in a more reliable way
var playerPath = state.saved.prefs.externalPlayerPath
var playerName = playerPath ? path.basename(playerPath).split('.')[0] : 'VLC'
castIcon = 'tv' castIcon = 'tv'
castType = playerName castType = state.getExternalPlayerName()
isCast = false isCast = false
} else if (state.playing.location === 'error') { } else if (state.playing.location === 'error') {
castIcon = 'error_outline' castIcon = 'error_outline'
@@ -295,15 +307,15 @@ function renderCastScreen (state) {
isCast = false isCast = false
} }
var isStarting = state.playing.location.endsWith('-pending') const isStarting = state.playing.location.endsWith('-pending')
var castName = state.playing.castName const castName = state.playing.castName
var castStatus let castStatus
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...' if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
else castStatus = '' else castStatus = ''
// Show a nice title image, if possible // Show a nice title image, if possible
var style = { const style = {
backgroundImage: cssBackgroundImagePoster(state) backgroundImage: cssBackgroundImagePoster(state)
} }
@@ -321,12 +333,12 @@ function renderCastScreen (state) {
function renderCastOptions (state) { function renderCastOptions (state) {
if (!state.devices.castMenu) return if (!state.devices.castMenu) return
var {location, devices} = state.devices.castMenu const {location, devices} = state.devices.castMenu
var player = state.devices[location] const player = state.devices[location]
var items = devices.map(function (device, ix) { const items = devices.map(function (device, ix) {
var isSelected = player.device === device const isSelected = player.device === device
var name = device.name const name = device.name
return ( return (
<li key={ix} onClick={dispatcher('selectCastDevice', ix)}> <li key={ix} onClick={dispatcher('selectCastDevice', ix)}>
<i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i> <i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
@@ -343,11 +355,11 @@ function renderCastOptions (state) {
} }
function renderSubtitleOptions (state) { function renderSubtitleOptions (state) {
var subtitles = state.playing.subtitles const subtitles = state.playing.subtitles
if (!subtitles.tracks.length || !subtitles.showMenu) return if (!subtitles.tracks.length || !subtitles.showMenu) return
var items = subtitles.tracks.map(function (track, ix) { const items = subtitles.tracks.map(function (track, ix) {
var isSelected = state.playing.subtitles.selectedIndex === ix const isSelected = state.playing.subtitles.selectedIndex === ix
return ( return (
<li key={ix} onClick={dispatcher('selectSubtitle', ix)}> <li key={ix} onClick={dispatcher('selectSubtitle', ix)}>
<i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i> <i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
@@ -356,8 +368,8 @@ function renderSubtitleOptions (state) {
) )
}) })
var noneSelected = state.playing.subtitles.selectedIndex === -1 const noneSelected = state.playing.subtitles.selectedIndex === -1
var noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked') const noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked')
return ( return (
<ul key='subtitle-options' className='options-list'> <ul key='subtitle-options' className='options-list'>
{items} {items}
@@ -370,32 +382,39 @@ function renderSubtitleOptions (state) {
} }
function renderPlayerControls (state) { function renderPlayerControls (state) {
var positionPercent = 100 * state.playing.currentTime / state.playing.duration const positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' } const playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
var captionsClass = state.playing.subtitles.tracks.length === 0 const captionsClass = state.playing.subtitles.tracks.length === 0
? 'disabled' ? 'disabled'
: state.playing.subtitles.selectedIndex >= 0 : state.playing.subtitles.selectedIndex >= 0
? 'active' ? 'active'
: '' : ''
const prevClass = Playlist.hasPrevious(state) ? '' : 'disabled'
const nextClass = Playlist.hasNext(state) ? '' : 'disabled'
var elements = [ const elements = [
<div key='playback-bar' className='playback-bar'> <div key='playback-bar' className='playback-bar'>
{renderLoadingBar(state)} {renderLoadingBar(state)}
<div <div
key='cursor' key='cursor'
className='playback-cursor' className='playback-cursor'
style={playbackCursorStyle} style={playbackCursorStyle} />
/>
<div <div
key='scrub-bar' key='scrub-bar'
className='scrub-bar' className='scrub-bar'
draggable='true' draggable='true'
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={handleScrub} onClick={handleScrub}
onDrag={handleScrub} onDrag={handleScrub} />
/>
</div>, </div>,
<i
key='skip-previous'
className={'icon skip-previous float-left ' + prevClass}
onClick={dispatcher('previousTrack')}>
skip_previous
</i>,
<i <i
key='play' key='play'
className='icon play-pause float-left' className='icon play-pause float-left'
@@ -403,6 +422,13 @@ function renderPlayerControls (state) {
{state.playing.isPaused ? 'play_arrow' : 'pause'} {state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>, </i>,
<i
key='skip-next'
className={'icon skip-next float-left ' + nextClass}
onClick={dispatcher('nextTrack')}>
skip_next
</i>,
<i <i
key='fullscreen' key='fullscreen'
className='icon fullscreen float-right' className='icon fullscreen float-right'
@@ -412,7 +438,7 @@ function renderPlayerControls (state) {
] ]
if (state.playing.type === 'video') { if (state.playing.type === 'video') {
// show closed captions icon // Show closed captions icon
elements.push(( elements.push((
<i <i
key='subtitles' key='subtitles'
@@ -424,24 +450,24 @@ function renderPlayerControls (state) {
} }
// 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 castTypes = ['chromecast', 'airplay', 'dlna'] const castTypes = ['chromecast', 'airplay', 'dlna']
var isCastingAnywhere = castTypes.some( const isCastingAnywhere = castTypes.some(
(castType) => state.playing.location.startsWith(castType)) (castType) => state.playing.location.startsWith(castType))
// Add the cast buttons. Icons for each cast type, connected/disconnected: // Add the cast buttons. Icons for each cast type, connected/disconnected:
var buttonIcons = { const buttonIcons = {
'chromecast': {true: 'cast_connected', false: 'cast'}, 'chromecast': {true: 'cast_connected', false: 'cast'},
'airplay': {true: 'airplay', false: 'airplay'}, 'airplay': {true: 'airplay', false: 'airplay'},
'dlna': {true: 'tv', false: 'tv'} 'dlna': {true: 'tv', false: 'tv'}
} }
castTypes.forEach(function (castType) { castTypes.forEach(function (castType) {
// Do we show this button (eg. the Chromecast button) at all? // Do we show this button (eg. the Chromecast button) at all?
var isCasting = state.playing.location.startsWith(castType) const isCasting = state.playing.location.startsWith(castType)
var player = state.devices[castType] const player = state.devices[castType]
if ((!player || player.getDevices().length === 0) && !isCasting) return if ((!player || player.getDevices().length === 0) && !isCasting) return
// Show the button. Three options for eg the Chromecast button: // Show the button. Three options for eg the Chromecast button:
var buttonClass, buttonHandler let buttonClass, buttonHandler
if (isCasting) { if (isCasting) {
// Option 1: we are currently connected to Chromecast. Button stops the cast. // Option 1: we are currently connected to Chromecast. Button stops the cast.
buttonClass = 'active' buttonClass = 'active'
@@ -455,7 +481,7 @@ function renderPlayerControls (state) {
buttonClass = '' buttonClass = ''
buttonHandler = dispatcher('toggleCastMenu', castType) buttonHandler = dispatcher('toggleCastMenu', castType)
} }
var buttonIcon = buttonIcons[castType][isCasting] const buttonIcon = buttonIcons[castType][isCasting]
elements.push(( elements.push((
<i <i
@@ -468,20 +494,18 @@ function renderPlayerControls (state) {
}) })
// Render volume slider // Render volume slider
var volume = state.playing.volume const volume = state.playing.volume
var volumeIcon = 'volume_' + ( const volumeIcon = 'volume_' + (
volume === 0 ? 'off' volume === 0 ? 'off'
: volume < 0.3 ? 'mute' : volume < 0.3 ? 'mute'
: volume < 0.6 ? 'down' : volume < 0.6 ? 'down'
: 'up') : 'up')
var volumeStyle = { const volumeStyle = {
background: '-webkit-gradient(linear, left top, right top, ' + background: '-webkit-gradient(linear, left top, right top, ' +
'color-stop(' + (volume * 100) + '%, #eee), ' + 'color-stop(' + (volume * 100) + '%, #eee), ' +
'color-stop(' + (volume * 100) + '%, #727272))' 'color-stop(' + (volume * 100) + '%, #727272))'
} }
// TODO: dcposch change the range input to use value / onChanged instead of
// "readonly" / onMouse[Down,Move,Up]
elements.push(( elements.push((
<div key='volume' className='volume float-left'> <div key='volume' className='volume float-left'>
<i <i
@@ -494,21 +518,20 @@ function renderPlayerControls (state) {
type='range' min='0' max='1' step='0.05' type='range' min='0' max='1' step='0.05'
value={volume} value={volume}
onChange={handleVolumeScrub} onChange={handleVolumeScrub}
style={volumeStyle} style={volumeStyle} />
/>
</div> </div>
)) ))
// Show video playback progress // Show video playback progress
var currentTimeStr = formatTime(state.playing.currentTime) const currentTimeStr = formatTime(state.playing.currentTime)
var durationStr = formatTime(state.playing.duration) const durationStr = formatTime(state.playing.duration)
elements.push(( elements.push((
<span key='time' className='time float-left'> <span key='time' className='time float-left'>
{currentTimeStr} / {durationStr} {currentTimeStr} / {durationStr}
</span> </span>
)) ))
// render playback rate // Render playback rate
if (state.playing.playbackRate !== 1) { if (state.playing.playbackRate !== 1) {
elements.push(( elements.push((
<span key='rate' className='rate float-left'> <span key='rate' className='rate float-left'>
@@ -518,7 +541,9 @@ function renderPlayerControls (state) {
} }
return ( return (
<div key='controls' className='controls'> <div key='controls' className='controls'
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
{elements} {elements}
{renderCastOptions(state)} {renderCastOptions(state)}
{renderSubtitleOptions(state)} {renderSubtitleOptions(state)}
@@ -528,7 +553,7 @@ function renderPlayerControls (state) {
function handleDragStart (e) { function handleDragStart (e) {
// Prevent the cursor from changing, eg to a green + icon on Mac // Prevent the cursor from changing, eg to a green + icon on Mac
if (e.dataTransfer) { if (e.dataTransfer) {
var dt = e.dataTransfer const dt = e.dataTransfer
dt.effectAllowed = 'none' dt.effectAllowed = 'none'
} }
} }
@@ -537,9 +562,9 @@ function renderPlayerControls (state) {
function handleScrub (e) { function handleScrub (e) {
if (!e.clientX) return if (!e.clientX) return
dispatch('mediaMouseMoved') dispatch('mediaMouseMoved')
var windowWidth = document.querySelector('body').clientWidth const windowWidth = document.querySelector('body').clientWidth
var fraction = e.clientX / windowWidth const fraction = e.clientX / windowWidth
var position = fraction * state.playing.duration /* seconds */ const position = fraction * state.playing.duration /* seconds */
dispatch('skipTo', position) dispatch('skipTo', position)
} }
@@ -570,18 +595,23 @@ function renderPlayerControls (state) {
// Renders the loading bar. Shows which parts of the torrent are loaded, which // Renders the loading bar. Shows which parts of the torrent are loaded, which
// can be 'spongey' / non-contiguous // can be 'spongey' / non-contiguous
function renderLoadingBar (state) { function renderLoadingBar (state) {
var torrentSummary = state.getPlayingTorrentSummary() if (config.IS_TEST) return // Don't integration test the loading bar. Screenshots won't match.
const torrentSummary = state.getPlayingTorrentSummary()
if (!torrentSummary.progress) { if (!torrentSummary.progress) {
return [] return null
} }
// Find all contiguous parts of the torrent which are loaded // Find all contiguous parts of the torrent which are loaded
var prog = torrentSummary.progress const prog = torrentSummary.progress
var fileProg = prog.files[state.playing.fileIndex] const fileProg = prog.files[state.playing.fileIndex]
var parts = []
var lastPiecePresent = false if (!fileProg) return null
for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
var partPresent = Bitfield.prototype.get.call(prog.bitfield, i) const parts = []
let lastPiecePresent = false
for (let i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
const partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
if (partPresent && !lastPiecePresent) { if (partPresent && !lastPiecePresent) {
parts.push({start: i - fileProg.startPiece, count: 1}) parts.push({start: i - fileProg.startPiece, count: 1})
} else if (partPresent) { } else if (partPresent) {
@@ -591,23 +621,24 @@ function renderLoadingBar (state) {
} }
// Output some bars to show which parts of the file are loaded // Output some bars to show which parts of the file are loaded
var loadingBarElems = parts.map(function (part, i) { const loadingBarElems = parts.map(function (part, i) {
var style = { const style = {
left: (100 * part.start / fileProg.numPieces) + '%', left: (100 * part.start / fileProg.numPieces) + '%',
width: (100 * part.count / fileProg.numPieces) + '%' width: (100 * part.count / fileProg.numPieces) + '%'
} }
return (<div key={i} className='loading-bar-part' style={style} />) return (<div key={i} className='loading-bar-part' style={style} />)
}) })
return (<div key='loading-bar' className='loading-bar'>{loadingBarElems}</div>) return (<div key='loading-bar' className='loading-bar'>{loadingBarElems}</div>)
} }
// 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 = state.getPlayingTorrentSummary() const torrentSummary = state.getPlayingTorrentSummary()
var posterPath = TorrentSummary.getPosterPath(torrentSummary) const posterPath = TorrentSummary.getPosterPath(torrentSummary)
if (!posterPath) return '' if (!posterPath) return ''
return cssBackgroundImageDarkGradient() + `, url(${posterPath})` return cssBackgroundImageDarkGradient() + `, url('${posterPath}')`
} }
function cssBackgroundImageDarkGradient () { function cssBackgroundImageDarkGradient () {
@@ -620,12 +651,12 @@ function formatTime (time) {
return '0:00' return '0:00'
} }
var hours = Math.floor(time / 3600) let hours = Math.floor(time / 3600)
var minutes = Math.floor(time % 3600 / 60) let minutes = Math.floor(time % 3600 / 60)
if (hours > 0) { if (hours > 0) {
minutes = zeroFill(2, minutes) minutes = zeroFill(2, minutes)
} }
var seconds = zeroFill(2, Math.floor(time % 60)) let seconds = zeroFill(2, Math.floor(time % 60))
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
} }

View File

@@ -0,0 +1,211 @@
const path = require('path')
const React = require('react')
const colors = require('material-ui/styles/colors')
const Checkbox = require('material-ui/Checkbox').default
const RaisedButton = require('material-ui/RaisedButton').default
const Heading = require('../components/heading')
const PathSelector = require('../components/path-selector')
const {dispatch} = require('../lib/dispatcher')
const config = require('../../config')
class PreferencesPage extends React.Component {
constructor (props) {
super(props)
this.handleDownloadPathChange =
this.handleDownloadPathChange.bind(this)
this.handleOpenExternalPlayerChange =
this.handleOpenExternalPlayerChange.bind(this)
this.handleExternalPlayerPathChange =
this.handleExternalPlayerPathChange.bind(this)
this.handleStartupChange =
this.handleStartupChange.bind(this)
}
downloadPathSelector () {
return (
<Preference>
<PathSelector
dialog={{
title: 'Select download directory',
properties: [ 'openDirectory' ]
}}
onChange={this.handleDownloadPathChange}
title='Download location'
value={this.props.state.unsaved.prefs.downloadPath} />
</Preference>
)
}
handleDownloadPathChange (filePath) {
dispatch('updatePreferences', 'downloadPath', filePath)
}
openExternalPlayerCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={!this.props.state.unsaved.prefs.openExternalPlayer}
label={'Play torrent media files using WebTorrent'}
onCheck={this.handleOpenExternalPlayerChange} />
</Preference>
)
}
handleOpenExternalPlayerChange (e, isChecked) {
dispatch('updatePreferences', 'openExternalPlayer', !isChecked)
}
highestPlaybackPriorityCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={this.props.state.unsaved.prefs.highestPlaybackPriority}
label={'Highest Playback Priority'}
onCheck={this.handleHighestPlaybackPriorityChange}
/>
<p>Pauses all active torrents to allow playback to use all of the available bandwidth.</p>
</Preference>
)
}
handleHighestPlaybackPriorityChange (e, isChecked) {
dispatch('updatePreferences', 'highestPlaybackPriority', isChecked)
}
externalPlayerPathSelector () {
const playerPath = this.props.state.unsaved.prefs.externalPlayerPath
const playerName = this.props.state.getExternalPlayerName()
const description = this.props.state.unsaved.prefs.openExternalPlayer
? `Torrent media files will always play in ${playerName}.`
: `Torrent media files will play in ${playerName} if WebTorrent cannot play them.`
return (
<Preference>
<p>{description}</p>
<PathSelector
dialog={{
title: 'Select media player app',
properties: [ 'openFile' ]
}}
displayValue={playerName}
onChange={this.handleExternalPlayerPathChange}
title='External player'
value={playerPath ? path.dirname(playerPath) : null} />
</Preference>
)
}
handleExternalPlayerPathChange (filePath) {
dispatch('updatePreferences', 'externalPlayerPath', filePath)
}
setDefaultAppButton () {
const isFileHandler = this.props.state.unsaved.prefs.isFileHandler
if (isFileHandler) {
return (
<Preference>
<p>WebTorrent is your default torrent app. Hooray!</p>
</Preference>
)
}
return (
<Preference>
<p>WebTorrent is not currently the default torrent app.</p>
<RaisedButton
className='control'
onClick={this.handleSetDefaultApp}
label='Make WebTorrent the default' />
</Preference>
)
}
handleStartupChange (e, isChecked) {
dispatch('updatePreferences', 'startup', isChecked)
}
setStartupSection () {
if (config.IS_PORTABLE) {
return
}
return (
<PreferencesSection title='Startup'>
<Preference>
<Checkbox
className='control'
checked={this.props.state.unsaved.prefs.startup}
label={'Open WebTorrent on startup.'}
onCheck={this.handleStartupChange}
/>
</Preference>
</PreferencesSection>
)
}
handleSetDefaultApp () {
dispatch('updatePreferences', 'isFileHandler', true)
}
render () {
const style = {
color: colors.grey400,
marginLeft: 25,
marginRight: 25
}
return (
<div style={style}>
<PreferencesSection title='Downloads'>
{this.downloadPathSelector()}
</PreferencesSection>
<PreferencesSection title='Playback'>
{this.openExternalPlayerCheckbox()}
{this.externalPlayerPathSelector()}
{this.highestPlaybackPriorityCheckbox()}
</PreferencesSection>
<PreferencesSection title='Default torrent app'>
{this.setDefaultAppButton()}
</PreferencesSection>
{this.setStartupSection()}
</div>
)
}
}
class PreferencesSection extends React.Component {
static get propTypes () {
return {
title: React.PropTypes.string
}
}
render () {
const style = {
marginBottom: 25,
marginTop: 25
}
return (
<div style={style}>
<Heading level={2}>{this.props.title}</Heading>
{this.props.children}
</div>
)
}
}
class Preference extends React.Component {
render () {
const style = { marginBottom: 10 }
return (<div style={style}>{this.props.children}</div>)
}
}
module.exports = PreferencesPage

View File

@@ -1,15 +1,18 @@
const React = require('react') const React = require('react')
const prettyBytes = require('prettier-bytes') const prettyBytes = require('prettier-bytes')
const Checkbox = require('material-ui/Checkbox').default
const LinearProgress = require('material-ui/LinearProgress').default
const TorrentSummary = require('../lib/torrent-summary') const TorrentSummary = require('../lib/torrent-summary')
const TorrentPlayer = require('../lib/torrent-player') const TorrentPlayer = require('../lib/torrent-player')
const {dispatcher} = require('../lib/dispatcher') const {dispatcher} = require('../lib/dispatcher')
module.exports = class TorrentList extends React.Component { module.exports = class TorrentList extends React.Component {
render () { render () {
var state = this.props.state const state = this.props.state
var contents = [] const contents = []
if (state.downloadPathStatus === 'missing') { if (state.downloadPathStatus === 'missing') {
contents.push( contents.push(
<div key='torrent-missing-path'> <div key='torrent-missing-path'>
@@ -21,7 +24,7 @@ module.exports = class TorrentList extends React.Component {
</div> </div>
) )
} }
var torrentElems = state.saved.torrents.map( const torrentElems = state.saved.torrents.map(
(torrentSummary) => this.renderTorrent(torrentSummary) (torrentSummary) => this.renderTorrent(torrentSummary)
) )
contents.push(...torrentElems) contents.push(...torrentElems)
@@ -39,30 +42,27 @@ module.exports = class TorrentList extends React.Component {
} }
renderTorrent (torrentSummary) { renderTorrent (torrentSummary) {
var state = this.props.state const state = this.props.state
var infoHash = torrentSummary.infoHash const infoHash = torrentSummary.infoHash
var isSelected = infoHash && state.selectedInfoHash === infoHash const isSelected = infoHash && state.selectedInfoHash === infoHash
// Background image: show some nice visuals, like a frame from the movie, if possible // Background image: show some nice visuals, like a frame from the movie, if possible
var style = {} const style = {}
if (torrentSummary.posterFileName) { if (torrentSummary.posterFileName) {
var gradient = isSelected const gradient = 'linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 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%)' const posterPath = TorrentSummary.getPosterPath(torrentSummary)
: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)' style.backgroundImage = `${gradient}, url('${posterPath}')`
var posterPath = TorrentSummary.getPosterPath(torrentSummary)
style.backgroundImage = gradient + `, url('${posterPath}')`
} }
// Foreground: name of the torrent, basic info like size, play button, // Foreground: name of the torrent, basic info like size, play button,
// cast buttons if available, and delete // cast buttons if available, and delete
var classes = ['torrent'] const classes = ['torrent']
// playStatus turns the play button into a loading spinner or error icon
if (torrentSummary.playStatus) classes.push(torrentSummary.playStatus)
if (isSelected) classes.push('selected') if (isSelected) classes.push('selected')
if (!infoHash) classes.push('disabled') if (!infoHash) classes.push('disabled')
if (!torrentSummary.torrentKey) throw new Error('Missing torrentKey') if (!torrentSummary.torrentKey) throw new Error('Missing torrentKey')
return ( return (
<div <div
id={torrentSummary.testID && ('torrent-' + torrentSummary.testID)}
key={torrentSummary.torrentKey} key={torrentSummary.torrentKey}
style={style} style={style}
className={classes.join(' ')} className={classes.join(' ')}
@@ -71,48 +71,96 @@ module.exports = class TorrentList extends React.Component {
{this.renderTorrentMetadata(torrentSummary)} {this.renderTorrentMetadata(torrentSummary)}
{infoHash ? this.renderTorrentButtons(torrentSummary) : null} {infoHash ? this.renderTorrentButtons(torrentSummary) : null}
{isSelected ? this.renderTorrentDetails(torrentSummary) : null} {isSelected ? this.renderTorrentDetails(torrentSummary) : null}
<hr />
</div> </div>
) )
} }
// Show name, download status, % complete // Show name, download status, % complete
renderTorrentMetadata (torrentSummary) { renderTorrentMetadata (torrentSummary) {
var name = torrentSummary.name || 'Loading torrent...' const name = torrentSummary.name || 'Loading torrent...'
var elements = [( const elements = [(
<div key='name' className='name ellipsis'>{name}</div> <div key='name' className='name ellipsis'>{name}</div>
)] )]
// If it's downloading/seeding then show progress info // If it's downloading/seeding then show progress info
var prog = torrentSummary.progress const prog = torrentSummary.progress
let progElems
if (torrentSummary.error) { if (torrentSummary.error) {
elements.push( progElems = [getErrorMessage(torrentSummary)]
<div key='progress-info' className='ellipsis'>
{getErrorMessage(torrentSummary)}
</div>
)
} else if (torrentSummary.status !== 'paused' && prog) { } else if (torrentSummary.status !== 'paused' && prog) {
elements.push( progElems = [
<div key='progress-info' className='ellipsis'> renderDownloadCheckbox(),
{renderPercentProgress()} renderTorrentStatus(),
{renderTotalProgress()} renderProgressBar(),
{renderPeers()} renderPercentProgress(),
{renderDownloadSpeed()} renderTotalProgress(),
{renderUploadSpeed()} renderPeers(),
{renderEta()} renderSpeeds(),
renderEta()
]
} else {
progElems = [
renderDownloadCheckbox(),
renderTorrentStatus()
]
}
elements.push(
<div key='progress-info' className='ellipsis'>
{progElems}
</div>
)
return (<div key='metadata' className='metadata'>{elements}</div>)
function renderDownloadCheckbox () {
const infoHash = torrentSummary.infoHash
const isActive = ['downloading', 'seeding'].includes(torrentSummary.status)
return (
<Checkbox
key='download-button'
className={'control download ' + torrentSummary.status}
style={{
display: 'inline-block',
width: 32
}}
iconStyle={{
width: 20,
height: 20
}}
checked={isActive}
onClick={stopPropagation}
onCheck={dispatcher('toggleTorrent', infoHash)} />
)
}
function renderProgressBar () {
const progress = Math.floor(100 * prog.progress)
const styles = {
wrapper: {
display: 'inline-block',
marginRight: 8
},
progress: {
height: 8,
width: 30
}
}
return (
<div style={styles.wrapper}>
<LinearProgress style={styles.progress} mode='determinate' value={progress} />
</div> </div>
) )
} }
return (<div key='metadata' className='metadata'>{elements}</div>)
function renderPercentProgress () { function renderPercentProgress () {
var progress = Math.floor(100 * prog.progress) const progress = Math.floor(100 * prog.progress)
return (<span key='percent-progress'>{progress}%</span>) return (<span key='percent-progress'>{progress}%</span>)
} }
function renderTotalProgress () { function renderTotalProgress () {
var downloaded = prettyBytes(prog.downloaded) const downloaded = prettyBytes(prog.downloaded)
var total = prettyBytes(prog.length || 0) const total = prettyBytes(prog.length || 0)
if (downloaded === total) { if (downloaded === total) {
return (<span key='total-progress'>{downloaded}</span>) return (<span key='total-progress'>{downloaded}</span>)
} else { } else {
@@ -122,111 +170,78 @@ module.exports = class TorrentList extends React.Component {
function renderPeers () { function renderPeers () {
if (prog.numPeers === 0) return if (prog.numPeers === 0) return
var count = prog.numPeers === 1 ? 'peer' : 'peers' const count = prog.numPeers === 1 ? 'peer' : 'peers'
return (<span key='peers'>{prog.numPeers} {count}</span>) return (<span key='peers'>{prog.numPeers} {count}</span>)
} }
function renderDownloadSpeed () { function renderSpeeds () {
if (prog.downloadSpeed === 0) return let str = ''
return (<span key='download'> {prettyBytes(prog.downloadSpeed)}/s</span>) if (prog.downloadSpeed > 0) str += ' ↓ ' + prettyBytes(prog.downloadSpeed) + '/s'
} if (prog.uploadSpeed > 0) str += ' ↑ ' + prettyBytes(prog.uploadSpeed) + '/s'
if (str === '') return
function renderUploadSpeed () { return (<span key='download'>{str}</span>)
if (prog.uploadSpeed === 0) return
return (<span key='upload'> {prettyBytes(prog.uploadSpeed)}/s</span>)
} }
function renderEta () { function renderEta () {
var downloaded = prog.downloaded const downloaded = prog.downloaded
var total = prog.length || 0 const total = prog.length || 0
var missing = total - downloaded const missing = total - downloaded
var downloadSpeed = prog.downloadSpeed const downloadSpeed = prog.downloadSpeed
if (downloadSpeed === 0 || missing === 0) return if (downloadSpeed === 0 || missing === 0) return
var rawEta = missing / downloadSpeed const rawEta = missing / downloadSpeed
var hours = Math.floor(rawEta / 3600) % 24 const hours = Math.floor(rawEta / 3600) % 24
var minutes = Math.floor(rawEta / 60) % 60 const minutes = Math.floor(rawEta / 60) % 60
var seconds = Math.floor(rawEta % 60) const seconds = Math.floor(rawEta % 60)
// Only display hours and minutes if they are greater than 0 but always // Only display hours and minutes if they are greater than 0 but always
// display minutes if hours is being displayed // display minutes if hours is being displayed
var hoursStr = hours ? hours + 'h' : '' const hoursStr = hours ? hours + 'h' : ''
var minutesStr = (hours || minutes) ? minutes + 'm' : '' const minutesStr = (hours || minutes) ? minutes + 'm' : ''
var secondsStr = seconds + 's' const secondsStr = seconds + 's'
return (<span>ETA: {hoursStr} {minutesStr} {secondsStr}</span>) return (<span>{hoursStr} {minutesStr} {secondsStr} remaining</span>)
}
function renderTorrentStatus () {
let status
if (torrentSummary.status === 'paused') {
if (!torrentSummary.progress) status = ''
else if (torrentSummary.progress.progress === 1) status = 'Not seeding'
else status = 'Paused'
} else if (torrentSummary.status === 'downloading') {
status = 'Downloading'
} else if (torrentSummary.status === 'seeding') {
status = 'Seeding'
} else { // torrentSummary.status is 'new' or something unexpected
status = ''
}
return (<span>{status}</span>)
} }
} }
// Download button toggles between torrenting (DL/seed) and paused // Download button toggles between torrenting (DL/seed) and paused
// Play button starts streaming the torrent immediately, unpausing if needed // Play button starts streaming the torrent immediately, unpausing if needed
renderTorrentButtons (torrentSummary) { renderTorrentButtons (torrentSummary) {
var infoHash = torrentSummary.infoHash const infoHash = torrentSummary.infoHash
var playIcon, playTooltip, playClass // Only show the play/dowload buttons for torrents that contain playable media
if (torrentSummary.playStatus === 'timeout') { let playButton
playIcon = 'warning' if (!torrentSummary.error && TorrentPlayer.isPlayableTorrentSummary(torrentSummary)) {
playTooltip = 'Playback timed out. No seeds? No internet? Click to try again.'
} else {
playIcon = 'play_arrow'
playTooltip = 'Start streaming'
}
var downloadIcon, downloadTooltip
if (torrentSummary.status === 'seeding') {
downloadIcon = 'file_upload'
downloadTooltip = 'Seeding. Click to stop.'
} else if (torrentSummary.status === 'downloading') {
downloadIcon = 'file_download'
downloadTooltip = 'Torrenting. Click to stop.'
} else {
downloadIcon = 'file_download'
downloadTooltip = 'Click to start torrenting.'
}
// Do we have a saved position? Show it using a radial progress bar on top
// of the play button, unless already showing a spinner there:
var positionElem
var willShowSpinner = torrentSummary.playStatus === 'requested'
var defaultFile = torrentSummary.files &&
torrentSummary.files[torrentSummary.defaultPlayFileIndex]
if (defaultFile && defaultFile.currentTime && !willShowSpinner) {
var fraction = defaultFile.currentTime / defaultFile.duration
positionElem = this.renderRadialProgressBar(fraction, 'radial-progress-large')
playClass = 'resume-position'
}
// Only show the play button for torrents that contain playable media
var playButton, downloadButton
var noErrors = !torrentSummary.error
if (noErrors && TorrentPlayer.isPlayableTorrentSummary(torrentSummary)) {
playButton = ( playButton = (
<i <i
key='play-button' key='play-button'
title={playTooltip} title='Start streaming'
className={'button-round icon play ' + playClass} className={'icon play'}
onClick={dispatcher('playFile', infoHash)}> onClick={dispatcher('playFile', infoHash)}>
{playIcon} play_circle_outline
</i>
)
}
if (noErrors) {
downloadButton = (
<i
key='download-button'
className={'button-round icon download ' + torrentSummary.status}
title={downloadTooltip}
onClick={dispatcher('toggleTorrent', infoHash)}>
{downloadIcon}
</i> </i>
) )
} }
return ( return (
<div key='buttons' className='buttons'> <div className='torrent-controls'>
{positionElem}
{playButton} {playButton}
{downloadButton}
<i <i
key='delete-button' key='delete-button'
className='icon delete' className='icon delete'
@@ -240,9 +255,9 @@ module.exports = class TorrentList extends React.Component {
// Show files, per-file download status and play buttons, and so on // Show files, per-file download status and play buttons, and so on
renderTorrentDetails (torrentSummary) { renderTorrentDetails (torrentSummary) {
var filesElement let filesElement
if (torrentSummary.error || !torrentSummary.files) { if (torrentSummary.error || !torrentSummary.files) {
var message = '' let message = ''
if (torrentSummary.error === 'path-missing') { if (torrentSummary.error === 'path-missing') {
// Special case error: this torrent's download dir or file is missing // Special case error: this torrent's download dir or file is missing
message = 'Missing path: ' + TorrentSummary.getFileOrFolder(torrentSummary) message = 'Missing path: ' + TorrentSummary.getFileOrFolder(torrentSummary)
@@ -263,14 +278,9 @@ module.exports = class TorrentList extends React.Component {
) )
} else { } else {
// We do know the files. List them and show download stats for each one // We do know the files. List them and show download stats for each one
var fileRows = torrentSummary.files const fileRows = torrentSummary.files
.filter((file) => !file.path.includes('/.____padding_file/')) .filter((file) => !file.path.includes('/.____padding_file/'))
.map((file, index) => ({ file, index })) .map((file, index) => ({ file, index }))
.sort(function (a, b) {
if (a.file.name < b.file.name) return -1
if (b.file.name < a.file.name) return 1
return 0
})
.map((object) => this.renderFileRow(torrentSummary, object.file, object.index)) .map((object) => this.renderFileRow(torrentSummary, object.file, object.index))
filesElement = ( filesElement = (
@@ -295,36 +305,39 @@ module.exports = class TorrentList extends React.Component {
renderFileRow (torrentSummary, file, index) { renderFileRow (torrentSummary, file, index) {
// First, find out how much of the file we've downloaded // First, find out how much of the file we've downloaded
// Are we even torrenting it? // Are we even torrenting it?
var isSelected = torrentSummary.selections && torrentSummary.selections[index] const isSelected = torrentSummary.selections && torrentSummary.selections[index]
var isDone = false // Are we finished torrenting it? let isDone = false // Are we finished torrenting it?
var progress = '' let progress = ''
if (torrentSummary.progress && torrentSummary.progress.files && if (torrentSummary.progress && torrentSummary.progress.files &&
torrentSummary.progress.files[index]) { torrentSummary.progress.files[index]) {
var fileProg = torrentSummary.progress.files[index] const fileProg = torrentSummary.progress.files[index]
isDone = fileProg.numPiecesPresent === fileProg.numPieces isDone = fileProg.numPiecesPresent === fileProg.numPieces
progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%' progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
} }
// Second, for media files where we saved our position, show how far we got // Second, for media files where we saved our position, show how far we got
var positionElem let positionElem
if (file.currentTime) { if (file.currentTime) {
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru // Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
positionElem = this.renderRadialProgressBar(file.currentTime / file.duration) positionElem = this.renderRadialProgressBar(file.currentTime / file.duration)
} }
// Finally, render the file as a table row // Finally, render the file as a table row
var isPlayable = TorrentPlayer.isPlayable(file) const isPlayable = TorrentPlayer.isPlayable(file)
var infoHash = torrentSummary.infoHash const infoHash = torrentSummary.infoHash
var icon let icon
var handleClick let handleClick
if (isPlayable) { if (isPlayable) {
icon = 'play_arrow' /* playable? add option to play */ icon = 'play_arrow' /* playable? add option to play */
handleClick = dispatcher('playFile', infoHash, index) handleClick = dispatcher('playFile', infoHash, index)
} else { } else {
icon = 'description' /* file icon, opens in OS default app */ icon = 'description' /* file icon, opens in OS default app */
handleClick = dispatcher('openItem', infoHash, index) handleClick = isDone
? dispatcher('openItem', infoHash, index)
: (e) => e.stopPropagation() // noop if file is not ready
} }
var rowClass = '' // TODO: add a css 'disabled' class to indicate that a file cannot be opened/streamed
let rowClass = ''
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
return ( return (
@@ -344,16 +357,16 @@ module.exports = class TorrentList extends React.Component {
</td> </td>
<td className='col-select' <td className='col-select'
onClick={dispatcher('toggleTorrentFile', infoHash, index)}> onClick={dispatcher('toggleTorrentFile', infoHash, index)}>
<i className='icon'>{isSelected ? 'close' : 'add'}</i> <i className='icon deselect-file'>{isSelected ? 'close' : 'add'}</i>
</td> </td>
</tr> </tr>
) )
} }
renderRadialProgressBar (fraction, cssClass) { renderRadialProgressBar (fraction, cssClass) {
var rotation = 360 * fraction const rotation = 360 * fraction
var transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'} const transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
var transformFix = {transform: 'rotate(' + rotation + 'deg)'} const transformFix = {transform: 'rotate(' + rotation + 'deg)'}
return ( return (
<div key='radial-progress' className={'radial-progress ' + cssClass}> <div key='radial-progress' className={'radial-progress ' + cssClass}>
@@ -372,8 +385,12 @@ module.exports = class TorrentList extends React.Component {
} }
} }
function stopPropagation (e) {
e.stopPropagation()
}
function getErrorMessage (torrentSummary) { function getErrorMessage (torrentSummary) {
var err = torrentSummary.error const err = torrentSummary.error
if (err === 'path-missing') { if (err === 'path-missing') {
return ( return (
<span> <span>

287
src/renderer/plugins.js Normal file
View File

@@ -0,0 +1,287 @@
const {resolve, basename} = require('path')
const notifier = require('node-notifier')
const crypto = require('crypto')
const config = require('../config')
module.exports = class Plugins {
constructor () {
// modules path
this.path = resolve(config.getConfigPath(), 'plugins')
log('Path: ', this.path)
this.availableExtensions = [
'onCheckForSubtitles'
]
this.forceUpdate = false
this.updating = false
this.watchers = []
this.state = {}
}
init (params) {
this.state = params.state
// initialize state
this.state.saved = Object.assign(this.state.saved || {})
// caches
this.plugins = config.getPlugins()
this.paths = this.getPaths(this.plugins)
this.id = this.getId(this.plugins)
this.modules = this.requirePlugins()
// TODO: fire an event when plugins finish updating and listen to that.
// Listen to plugin changes on config.
// New plugins added, plugins removed or updated.
// The actual plugin update action will take place in the MAIN process.
config.subscribe(() => {
const plugins = config.getPlugins()
if (plugins !== this.plugins) {
const id = this.getId(plugins)
if (this.id !== id) {
log('UPDATING...')
this.id = id
this.plugins = plugins
this.paths = this.getPaths(this.plugins)
}
}
})
// Plugins will be updated on the MAIN process after 5s.
if (this.needsUpdate()) {
setTimeout(() => {
this.init(params)
}, 6000)
log('Plugins need update, init scheduled')
return
}
this.loadPlugins()
this.initPlugins(params)
}
initPlugins (params) {
this.modules.forEach(plugin => {
if (plugin.init) {
plugin.init(params)
}
})
}
on (action, params) {
log(`ON ${action}:`, params)
this.modules.forEach(plugin => {
const actionName = this.capitalizeFirstLetter(action)
const methodName = `on${actionName}`
if (typeof plugin[methodName] === 'function') {
plugin[methodName](params)
}
})
}
capitalizeFirstLetter (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
didPluginsChange () {
return this.state.saved.installedPlugins !== this.id
}
hasPlugins () {
return !this.isEmptyObject(this.plugins)
}
isFirstInstall () {
return (!this.state.saved.installedPlugins && this.hasPlugins())
}
needsUpdate () {
return (this.didPluginsChange() || this.isFirstInstall())
}
getId (plugins) {
const hash = crypto.createHash('sha256')
hash.update(JSON.stringify(plugins))
return hash.digest('hex')
}
loadPlugins (err, localOnly = false) {
this.updating = false
// handle errors first
if (err) {
console.error(err.stack)
if (/not a recognized/.test(err.message) || /command not found/.test(err.message)) {
this.alert(
'Error updating plugins: We could not find the "npm" command. Make sure it\'s in $PATH'
)
return
}
this.alert(`Error updating plugins: Check '${this.path}/npm-debug.log' for more information.`)
return
}
// update state with latest plugins
this.state.saved.plugins = this.plugins
// cache modules
this.modules = this.requirePlugins()
// clear require cache
this.clearCache()
}
getPluginVersions () {
const paths_ = this.paths.plugins
return paths_.map(path => {
let version = null
try {
// eslint-disable-next-line import/no-dynamic-require
version = require(resolve(path, 'package.json')).version
} catch (err) { }
return [
basename(path),
version
]
})
}
clearCache () {
// clear require cache
for (const entry in require.cache) {
if (entry.indexOf(this.path) === 0) {
delete require.cache[entry]
}
}
}
isEmptyObject (obj) {
return (Object.keys(obj).length === 0)
}
alert (message) {
notifier.notify({
title: 'WebTorrent Plugins',
// icon: config.icon, // TODO: save icon in webtorrent local folder and set config.icon
message: message
})
}
subscribe (fn) {
this.watchers.push(fn)
return () => {
this.watchers.splice(this.watchers.indexOf(fn), 1)
}
}
isLocalPath (string) {
// matches unix and windows local paths
return string.match(/^(\/|[a-z]:\/)/i)
}
getPaths (plugins) {
const pluginNames = Object.keys(plugins)
return {
plugins: pluginNames.map(name => {
let url = plugins[name]
// plugin is already on a local folder
// directly load it from its current location
if (this.isLocalPath(url)) return url
// plugin will be installed with npm install from a remote url
return resolve(this.path, 'node_modules', name.split('#')[0])
})
}
}
exposesSupportedApi (plugin) {
if (!plugin) return false
return this.availableExtensions.some((methodName) => {
return (typeof plugin[methodName] === 'function')
})
}
requirePlugins () {
const {plugins} = this.paths
let installNeeded = false
const load = (path) => {
if (!path.match(/\/$/)) {
path += '/'
}
const rendererPath = `${path}renderer.js`
try {
const Plugin = require(rendererPath) // eslint-disable import/no-dynamic-require
const plugin = new Plugin()
const exposed = this.exposesSupportedApi(plugin)
if (!exposed) {
log('Plugin not exposing any available extensions.', rendererPath, Object.keys(plugin))
return
}
// populate the name for internal errors here
plugin._name = basename(rendererPath)
return plugin
} catch (err) {
log('Require plugins ERROR:', err)
this.alert(`Error loading plugin: ${rendererPath}`)
// plugin not installed
// node_modules removed? did a manual plugin uninstall?
// try installing and then loading if successfull
installNeeded = true
}
}
// Plugin installation happens on the MAIN process.
// If plugins haven't finished installing, wait for them.
if (installNeeded) {
log('Plugins install needed, wait...')
setTimeout(() => {
this.requirePlugins()
}, 3000)
}
return plugins.map(load).filter(v => Boolean(v))
}
// decorates the base object by calling plugin[key]
// for all the available plugins
decorateObject (base, key) {
let decorated = base
this.modules.forEach(plugin => {
if (plugin[key]) {
const res = plugin[key](decorated)
if (res && typeof res === 'object') {
decorated = res
} else {
this.alert(`Plugin error: "${plugin._name}": invalid return type for \`${key}\``)
}
}
})
return decorated
}
}
/**
* Logs passed arguments to console using a prefix.
*
*/
function log () {
const prefix = '[ PLUGINS.Renderer ]-->'
const args = [prefix]
for (var i = 0; i < arguments.length; ++i) {
args.push(arguments[i])
}
console.log.apply(console, args)
}

View File

@@ -1,97 +0,0 @@
const React = require('react')
const Header = require('./header')
const Views = {
'home': require('./torrent-list'),
'player': require('./player'),
'create-torrent': require('./create-torrent'),
'preferences': require('./preferences')
}
const Modals = {
'open-torrent-address-modal': require('./open-torrent-address-modal'),
'remove-torrent-modal': require('./remove-torrent-modal'),
'update-available-modal': require('./update-available-modal'),
'unsupported-media-modal': require('./unsupported-media-modal')
}
module.exports = class App extends React.Component {
constructor (props) {
super(props)
this.state = props.state
}
render () {
var state = this.state
// Hide player controls while playing video, if the mouse stays still for a while
// Never hide the controls when:
// * The mouse is over the controls or we're scrubbing (see CSS)
// * The video is paused
// * The video is playing remotely on Chromecast or Airplay
var hideControls = state.location.url() === 'player' &&
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local' &&
state.playing.playbackRate === 1
var cls = [
'view-' + state.location.url(), /* e.g. view-home, view-player */
'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */
]
if (state.window.isFullScreen) cls.push('is-fullscreen')
if (state.window.isFocused) cls.push('is-focused')
if (hideControls) cls.push('hide-video-controls')
var vdom = (
<div className={'app ' + cls.join(' ')}>
<Header state={state} />
{this.getErrorPopover()}
<div key='content' className='content'>{this.getView()}</div>
{this.getModal()}
</div>
)
return vdom
}
getErrorPopover () {
var now = new Date().getTime()
var recentErrors = this.state.errors.filter((x) => now - x.time < 5000)
var hasErrors = recentErrors.length > 0
var errorElems = recentErrors.map(function (error, i) {
return (<div key={i} className='error'>{error.message}</div>)
})
return (
<div key='errors'
className={'error-popover ' + (hasErrors ? 'visible' : 'hidden')}>
<div key='title' className='title'>Error</div>
{errorElems}
</div>
)
}
getModal () {
var state = this.state
if (!state.modal) return
var ModalContents = Modals[state.modal.id]
return (
<div key='modal' className='modal'>
<div key='modal-background' className='modal-background' />
<div key='modal-content' className='modal-content'>
<ModalContents state={state} />
</div>
</div>
)
}
getView () {
var state = this.state
var View = Views[state.location.url()]
return (<View state={state} />)
}
}

View File

@@ -1,131 +0,0 @@
const React = require('react')
const createTorrent = require('create-torrent')
const path = require('path')
const prettyBytes = require('prettier-bytes')
const {dispatch, dispatcher} = require('../lib/dispatcher')
const CreateTorrentErrorPage = require('./create-torrent-error-page')
module.exports = class CreateTorrentPage extends React.Component {
render () {
var state = this.props.state
var info = state.location.current()
// Preprocess: exclude .DS_Store and other dotfiles
var files = info.files
.filter((f) => !f.name.startsWith('.'))
.map((f) => ({name: f.name, path: f.path, size: f.size}))
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
// First, extract the base folder that the files are all in
var pathPrefix = info.folderPath
if (!pathPrefix) {
pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
}
// Sanity check: show the number of files and total size
var numFiles = files.length
var totalBytes = files
.map((f) => f.size)
.reduce((a, b) => a + b, 0)
var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
// Then, use the name of the base folder (or sole file, for a single file torrent)
// as the default name. Show all files relative to the base folder.
var defaultName, basePath
if (files.length === 1) {
// Single file torrent: /a/b/foo.jpg -> torrent name 'foo.jpg', path '/a/b'
defaultName = files[0].name
basePath = pathPrefix
} else {
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name 'b', path '/a'
defaultName = path.basename(pathPrefix)
basePath = path.dirname(pathPrefix)
}
var maxFileElems = 100
var fileElems = files.slice(0, maxFileElems).map(function (file, i) {
var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path)
return (<div key={i}>{relativePath}</div>)
})
if (files.length > maxFileElems) {
fileElems.push(<div key='more'>+ {maxFileElems - files.length} more</div>)
}
var trackers = createTorrent.announceList.join('\n')
var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed'
return (
<div className='create-torrent'>
<h2>Create torrent {defaultName}</h2>
<div key='info' className='torrent-info'>
{torrentInfo}
</div>
<div key='path-prefix' className='torrent-attribute'>
<label>Path:</label>
<div className='torrent-attribute'>{pathPrefix}</div>
</div>
<div key='toggle' className={'expand-collapse ' + collapsedClass}
onClick={dispatcher('toggleCreateTorrentAdvanced')}>
{info.showAdvanced ? 'Basic' : 'Advanced'}
</div>
<div key='advanced' className={'create-torrent-advanced ' + collapsedClass}>
<div key='comment' className='torrent-attribute'>
<label>Comment:</label>
<textarea className='torrent-attribute torrent-comment' />
</div>
<div key='trackers' className='torrent-attribute'>
<label>Trackers:</label>
<textarea className='torrent-attribute torrent-trackers' defaultValue={trackers} />
</div>
<div key='private' className='torrent-attribute'>
<label>Private:</label>
<input type='checkbox' className='torrent-is-private' value='torrent-is-private' />
</div>
<div key='files' className='torrent-attribute'>
<label>Files:</label>
<div>{fileElems}</div>
</div>
</div>
<div key='buttons' className='float-right'>
<button key='cancel' className='button-flat light' onClick={dispatcher('cancel')}>Cancel</button>
<button key='create' className='button-raised' onClick={handleOK}>Create Torrent</button>
</div>
</div>
)
function handleOK () {
// TODO: dcposch use React refs instead
var announceList = document.querySelector('.torrent-trackers').value
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
var isPrivate = document.querySelector('.torrent-is-private').checked
var comment = document.querySelector('.torrent-comment').value.trim()
var options = {
// We can't let the user choose their own name if we want WebTorrent
// to use the files in place rather than creating a new folder.
// If we ever want to add support for that:
// name: document.querySelector('.torrent-name').value
name: defaultName,
path: basePath,
files: files,
announce: announceList,
private: isPrivate,
comment: comment
}
dispatch('createTorrent', options)
}
}
}
// Finds the longest common prefix
function findCommonPrefix (a, b) {
for (var i = 0; i < a.length && i < b.length; i++) {
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
}
if (i === a.length) return a
if (i === b.length) return b
return a.substring(0, i)
}

View File

@@ -1,32 +0,0 @@
const React = require('react')
const {dispatch, dispatcher} = require('../lib/dispatcher')
module.exports = class OpenTorrentAddressModal extends React.Component {
render () {
// TODO: dcposch remove janky inline <script>
return (
<div className='open-torrent-address-modal'>
<p><label>Enter torrent address or magnet link</label></p>
<p>
<input id='add-torrent-url' type='text' onKeyPress={handleKeyPress} />
</p>
<p className='float-right'>
<button className='button button-flat' onClick={dispatcher('exitModal')}>Cancel</button>
<button className='button button-raised' onClick={handleOK}>OK</button>
</p>
<script>document.querySelector('#add-torrent-url').focus()</script>
</div>
)
}
}
function handleKeyPress (e) {
if (e.which === 13) handleOK() /* hit Enter to submit */
}
function handleOK () {
dispatch('exitModal')
// TODO: dcposch use React refs instead
dispatch('addTorrent', document.querySelector('#add-torrent-url').value)
}

View File

@@ -1,217 +0,0 @@
const React = require('react')
const remote = require('electron').remote
const dialog = remote.dialog
const path = require('path')
const {dispatch} = require('../lib/dispatcher')
module.exports = class Preferences extends React.Component {
render () {
var state = this.props.state
return (
<div className='preferences'>
{renderGeneralSection(state)}
{renderPlaybackSection(state)}
</div>
)
}
}
function renderGeneralSection (state) {
return renderSection({
key: 'general',
title: 'General',
description: '',
icon: 'settings'
}, [
renderDownloadPathSelector(state),
renderFileHandlers(state)
])
}
function renderPlaybackSection (state) {
return renderSection({
title: 'Playback',
description: '',
icon: 'settings'
}, [
renderOpenExternalPlayerSelector(state),
renderExternalPlayerSelector(state)
])
}
function renderDownloadPathSelector (state) {
return renderFileSelector({
key: 'download-path',
label: 'Download Path',
description: 'Data from torrents will be saved here',
property: 'downloadPath',
options: {
title: 'Select download directory',
properties: [ 'openDirectory' ]
}
},
state.unsaved.prefs.downloadPath,
function (filePath) {
dispatch('updatePreferences', 'downloadPath', filePath)
})
}
function renderFileHandlers (state) {
var definition = {
key: 'file-handlers',
label: 'Handle Torrent Files'
}
var buttonText = state.unsaved.prefs.isFileHandler
? 'Remove default app for torrent files'
: 'Make WebTorrent the default app for torrent files'
var controls = [(
<button key='toggle-handlers'
className='btn'
onClick={toggleFileHandlers}>
{buttonText}
</button>
)]
return renderControlGroup(definition, controls)
function toggleFileHandlers () {
var isFileHandler = state.unsaved.prefs.isFileHandler
dispatch('updatePreferences', 'isFileHandler', !isFileHandler)
}
}
function renderExternalPlayerSelector (state) {
return renderFileSelector({
label: 'External Media Player',
description: 'Progam that will be used to play media externally',
property: 'externalPlayerPath',
options: {
title: 'Select media player executable',
properties: [ 'openFile' ]
}
},
state.unsaved.prefs.externalPlayerPath || '<VLC>',
function (filePath) {
if (path.extname(filePath) === '.app') {
// Get executable in packaged mac app
var name = path.basename(filePath, '.app')
filePath += '/Contents/MacOS/' + name
}
dispatch('updatePreferences', 'externalPlayerPath', filePath)
})
}
function renderOpenExternalPlayerSelector (state) {
return renderCheckbox({
key: 'open-external-player',
label: 'Play in External Player',
description: 'Media will play in external player',
property: 'openExternalPlayer',
value: state.saved.prefs.openExternalPlayer
},
state.unsaved.prefs.openExternalPlayer,
function (value) {
dispatch('updatePreferences', 'openExternalPlayer', value)
})
}
// Renders a prefs section.
// - definition should be {icon, title, description}
// - controls should be an array of vdom elements
function renderSection (definition, controls) {
var helpElem = !definition.description ? null : (
<div key='help' className='help text'>
<i className='icon'>help_outline</i>{definition.description}
</div>
)
return (
<section key={definition.key} className='section preferences-panel'>
<div className='section-container'>
<div key='heading' className='section-heading'>
<i className='icon'>{definition.icon}</i>{definition.title}
</div>
{helpElem}
<div key='body' className='section-body'>
{controls}
</div>
</div>
</section>
)
}
function renderCheckbox (definition, value, callback) {
var iconClass = 'icon clickable'
if (value) iconClass += ' enabled'
return (
<div key='{definition.key}' className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='preference-title'>{definition.label}</div>
</label>
<div className='controls'>
<label className='clickable' onClick={handleClick}>
<i
className={iconClass}
id='{definition.property}'
>
check_circle
</i>
<span className='checkbox-label'>{definition.description}</span>
</label>
</div>
</div>
</div>
)
function handleClick () {
callback(!value)
}
}
// Creates a file chooser
// - defition should be {label, description, options}
// options are passed to dialog.showOpenDialog
// - value should be the current pref, a file or folder path
// - callback takes a new file or folder path
function renderFileSelector (definition, value, callback) {
var controls = [(
<input
type='text'
className='file-picker-text'
key={definition.property}
id={definition.property}
disabled='disabled'
value={value} />
), (
<button
key={definition.property + '-btn'}
className='btn'
onClick={handleClick}>
<i className='icon'>folder_open</i>
</button>
)]
return renderControlGroup(definition, controls)
function handleClick () {
dialog.showOpenDialog(remote.getCurrentWindow(), definition.options, function (filenames) {
if (!Array.isArray(filenames)) return
callback(filenames[0])
})
}
}
function renderControlGroup (definition, controls) {
return (
<div key={definition.key} className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='preference-title'>{definition.label}</div>
<div className='preference-description'>{definition.description}</div>
</label>
<div className='controls'>
{controls}
</div>
</div>
</div>
)
}

View File

@@ -1,44 +0,0 @@
const React = require('react')
const electron = require('electron')
const path = require('path')
const {dispatcher} = require('../lib/dispatcher')
module.exports = class UnsupportedMediaModal extends React.Component {
render () {
var state = this.props.state
var err = state.modal.error
var message = (err && err.getMessage)
? err.getMessage()
: err
var playerPath = state.saved.prefs.externalPlayerPath
var playerName = playerPath
? path.basename(playerPath).split('.')[0]
: 'VLC'
var actionButton = state.modal.externalPlayerInstalled
? (<button className='button-raised' onClick={dispatcher('openExternalPlayer')}>Play in {playerName}</button>)
: (<button className='button-raised' onClick={() => this.onInstall}>Install VLC</button>)
var playerMessage = state.modal.externalPlayerNotFound
? 'Couldn\'t run external player. Please make sure it\'s installed.'
: ''
return (
<div>
<p><strong>Sorry, we can't play that file.</strong></p>
<p>{message}</p>
<p className='float-right'>
<button className='button-flat' onClick={dispatcher('backToList')}>Cancel</button>
{actionButton}
</p>
<p className='error-text'>{playerMessage}</p>
</div>
)
}
onInstall () {
electron.shell.openExternal('http://www.videolan.org/vlc/')
// TODO: dcposch send a dispatch rather than modifying state directly
var state = this.props.state
state.modal.externalPlayerInstalled = true // Assume they'll install it successfully
}
}

View File

@@ -2,26 +2,28 @@
// process from the main window. // process from the main window.
console.time('init') console.time('init')
var deepEqual = require('deep-equal') const crypto = require('crypto')
var defaultAnnounceList = require('create-torrent').announceList const deepEqual = require('deep-equal')
var electron = require('electron') const defaultAnnounceList = require('create-torrent').announceList
var fs = require('fs-extra') const electron = require('electron')
var hat = require('hat') const fs = require('fs')
var musicmetadata = require('musicmetadata') const mkdirp = require('mkdirp')
var networkAddress = require('network-address') const musicmetadata = require('musicmetadata')
var path = require('path') const networkAddress = require('network-address')
var WebTorrent = require('webtorrent') const path = require('path')
var zeroFill = require('zero-fill') const WebTorrent = require('webtorrent')
const zeroFill = require('zero-fill')
var crashReporter = require('../crash-reporter') const crashReporter = require('../crash-reporter')
var config = require('../config') const config = require('../config')
var torrentPoster = require('./lib/torrent-poster') const {TorrentKeyNotFoundError} = require('./lib/errors')
const torrentPoster = require('./lib/torrent-poster')
// Report when the process crashes // Report when the process crashes
crashReporter.init() crashReporter.init()
// Send & receive messages from the main window // Send & receive messages from the main window
var ipc = electron.ipcRenderer const ipc = electron.ipcRenderer
// Force use of webtorrent trackers on all torrents // Force use of webtorrent trackers on all torrents
global.WEBTORRENT_ANNOUNCE = defaultAnnounceList global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
@@ -31,7 +33,7 @@ global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
/** /**
* WebTorrent version. * WebTorrent version.
*/ */
var VERSION = require('../../package.json').version const VERSION = require('../../package.json').version
/** /**
* Version number in Azureus-style. Generated from major and minor semver version. * Version number in Azureus-style. Generated from major and minor semver version.
@@ -39,7 +41,7 @@ var VERSION = require('../../package.json').version
* '0.16.1' -> '0016' * '0.16.1' -> '0016'
* '1.2.5' -> '0102' * '1.2.5' -> '0102'
*/ */
var VERSION_STR = VERSION.match(/([0-9]+)/g) const VERSION_STR = VERSION.match(/([0-9]+)/g)
.slice(0, 2) .slice(0, 2)
.map((v) => zeroFill(2, v)) .map((v) => zeroFill(2, v))
.join('') .join('')
@@ -51,25 +53,27 @@ var VERSION_STR = VERSION.match(/([0-9]+)/g)
* For example: * For example:
* '-WW0102-'... * '-WW0102-'...
*/ */
var VERSION_PREFIX = '-WD' + VERSION_STR + '-' const VERSION_PREFIX = '-WD' + VERSION_STR + '-'
/**
* Generate an ephemeral peer ID each time.
*/
const PEER_ID = Buffer.from(VERSION_PREFIX + crypto.randomBytes(9).toString('base64'))
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid // Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
// client, as explained here: https://webtorrent.io/faq // client, as explained here: https://webtorrent.io/faq
var client = window.client = new WebTorrent({ let client = window.client = new WebTorrent({ peerId: PEER_ID })
peerId: Buffer.from(VERSION_PREFIX + hat(48))
})
// WebTorrent-to-HTTP streaming sever // WebTorrent-to-HTTP streaming sever
var server = null let server = null
// Used for diffing, so we only send progress updates when necessary // Used for diffing, so we only send progress updates when necessary
var prevProgress = null let prevProgress = null
init() init()
function init () { function init () {
client.on('warning', (err) => ipc.send('wt-warning', null, err.message)) listenToClientEvents()
client.on('error', (err) => ipc.send('wt-error', null, err.message))
ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) => ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) =>
startTorrenting(torrentKey, torrentID, path, fileModtimes, selections)) startTorrenting(torrentKey, torrentID, path, fileModtimes, selections))
@@ -83,8 +87,8 @@ function init () {
generateTorrentPoster(torrentKey)) generateTorrentPoster(torrentKey))
ipc.on('wt-get-audio-metadata', (e, infoHash, index) => ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
getAudioMetadata(infoHash, index)) getAudioMetadata(infoHash, index))
ipc.on('wt-start-server', (e, infoHash, index) => ipc.on('wt-start-server', (e, infoHash) =>
startServer(infoHash, index)) startServer(infoHash))
ipc.on('wt-stop-server', (e) => ipc.on('wt-stop-server', (e) =>
stopServer()) stopServer())
ipc.on('wt-select-files', (e, infoHash, selections) => ipc.on('wt-select-files', (e, infoHash, selections) =>
@@ -97,14 +101,20 @@ function init () {
true) true)
setInterval(updateTorrentProgress, 1000) setInterval(updateTorrentProgress, 1000)
console.timeEnd('init')
} }
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object function listenToClientEvents () {
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent- client.on('warning', (err) => ipc.send('wt-warning', null, err.message))
client.on('error', (err) => ipc.send('wt-error', null, err.message))
}
// Starts a given TorrentID, which can be an infohash, magnet URI, etc.
// Returns a WebTorrent object. See https://git.io/vik9M
function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections) { function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections) {
console.log('starting torrent %s: %s', torrentKey, torrentID) console.log('starting torrent %s: %s', torrentKey, torrentID)
var torrent = client.add(torrentID, { const torrent = client.add(torrentID, {
path: path, path: path,
fileModtimes: fileModtimes fileModtimes: fileModtimes
}) })
@@ -115,20 +125,19 @@ function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections)
// Only download the files the user wants, not necessarily all files // Only download the files the user wants, not necessarily all files
torrent.once('ready', () => selectFiles(torrent, selections)) torrent.once('ready', () => selectFiles(torrent, selections))
return torrent
} }
function stopTorrenting (infoHash) { function stopTorrenting (infoHash) {
var torrent = client.get(infoHash) console.log('--- STOP TORRENTING: ', infoHash)
const torrent = client.get(infoHash)
if (torrent) torrent.destroy() if (torrent) torrent.destroy()
} }
// Create a new torrent, start seeding // Create a new torrent, start seeding
function createTorrent (torrentKey, options) { function createTorrent (torrentKey, options) {
console.log('creating torrent', torrentKey, options) console.log('creating torrent', torrentKey, options)
var paths = options.files.map((f) => f.path) const paths = options.files.map((f) => f.path)
var torrent = client.seed(paths, options) const 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')
@@ -146,22 +155,22 @@ function addTorrentEvents (torrent) {
torrent.on('done', torrentDone) torrent.on('done', torrentDone)
function torrentMetadata () { function torrentMetadata () {
var info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipc.send('wt-metadata', torrent.key, info) ipc.send('wt-metadata', torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
} }
function torrentReady () { function torrentReady () {
var info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipc.send('wt-ready', torrent.key, info) ipc.send('wt-ready', torrent.key, info)
ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info) // TODO: hack ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
} }
function torrentDone () { function torrentDone () {
var info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipc.send('wt-done', torrent.key, info) ipc.send('wt-done', torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
@@ -194,19 +203,22 @@ function getTorrentFileInfo (file) {
} }
} }
// Every time we resolve a magnet URI, save the torrent file so that we never // Every time we resolve a magnet URI, save the torrent file so that we can use
// have to download it again. Never ask the DHT the same question twice. // it on next startup. Starting with the full torrent metadata will be faster
// than re-fetching it from peers using ut_metadata.
function saveTorrentFile (torrentKey) { function saveTorrentFile (torrentKey) {
var torrent = getTorrent(torrentKey) const torrent = getTorrent(torrentKey)
checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) { const torrentPath = path.join(config.TORRENT_PATH, torrent.infoHash + '.torrent')
var fileName = torrent.infoHash + '.torrent'
if (exists) { fs.access(torrentPath, fs.constants.R_OK, function (err) {
const fileName = torrent.infoHash + '.torrent'
if (!err) {
// We've already saved the file // We've already saved the file
return ipc.send('wt-file-saved', torrentKey, fileName) 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
fs.mkdir(config.TORRENT_PATH, function (_) { mkdirp(config.TORRENT_PATH, function (_) {
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)
@@ -216,26 +228,17 @@ function saveTorrentFile (torrentKey) {
}) })
} }
// Checks whether we've already resolved a given infohash to a torrent file
// Calls back with (torrentPath, exists). Logs, does not call back on error
function checkIfTorrentFileExists (infoHash, cb) {
var torrentPath = path.join(config.TORRENT_PATH, infoHash + '.torrent')
fs.exists(torrentPath, function (exists) {
cb(torrentPath, exists)
})
}
// Save a JPG that represents a torrent. // Save a JPG that represents a torrent.
// Auto chooses either a frame from a video file, an image, etc // Auto chooses either a frame from a video file, an image, etc
function generateTorrentPoster (torrentKey) { function generateTorrentPoster (torrentKey) {
var torrent = getTorrent(torrentKey) const torrent = getTorrent(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
fs.mkdirp(config.POSTER_PATH, function (err) { mkdirp(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 posterFileName = torrent.infoHash + extension const posterFileName = torrent.infoHash + extension
var posterFilePath = path.join(config.POSTER_PATH, posterFileName) const posterFilePath = path.join(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
@@ -246,7 +249,7 @@ function generateTorrentPoster (torrentKey) {
} }
function updateTorrentProgress () { function updateTorrentProgress () {
var progress = getTorrentProgress() const progress = getTorrentProgress()
// TODO: diff torrent-by-torrent, not once for the whole update // TODO: diff torrent-by-torrent, not once for the whole update
if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) { if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) {
return /* don't send heavy object if it hasn't changed */ return /* don't send heavy object if it hasn't changed */
@@ -257,19 +260,19 @@ function updateTorrentProgress () {
function getTorrentProgress () { function getTorrentProgress () {
// First, track overall progress // First, track overall progress
var progress = client.progress const progress = client.progress
var hasActiveTorrents = client.torrents.some(function (torrent) { const hasActiveTorrents = client.torrents.some(function (torrent) {
return torrent.progress !== 1 return torrent.progress !== 1
}) })
// Track progress for every file in each torrent // Track progress for every file in each torrent
// TODO: ideally this would be tracked by WebTorrent, which could do it // TODO: ideally this would be tracked by WebTorrent, which could do it
// more efficiently than looping over torrent.bitfield // more efficiently than looping over torrent.bitfield
var torrentProg = client.torrents.map(function (torrent) { const torrentProg = client.torrents.map(function (torrent) {
var fileProg = torrent.files && torrent.files.map(function (file, index) { const fileProg = torrent.files && torrent.files.map(function (file, index) {
var numPieces = file._endPiece - file._startPiece + 1 const numPieces = file._endPiece - file._startPiece + 1
var numPiecesPresent = 0 let numPiecesPresent = 0
for (var piece = file._startPiece; piece <= file._endPiece; piece++) { for (let piece = file._startPiece; piece <= file._endPiece; piece++) {
if (torrent.bitfield.get(piece)) numPiecesPresent++ if (torrent.bitfield.get(piece)) numPiecesPresent++
} }
return { return {
@@ -300,28 +303,28 @@ function getTorrentProgress () {
} }
} }
function startServer (infoHash, index) { function startServer (infoHash) {
var torrent = client.get(infoHash) const torrent = client.get(infoHash)
if (torrent.ready) startServerFromReadyTorrent(torrent, index) if (torrent.ready) startServerFromReadyTorrent(torrent)
else torrent.once('ready', () => startServerFromReadyTorrent(torrent, index)) else torrent.once('ready', () => startServerFromReadyTorrent(torrent))
} }
function startServerFromReadyTorrent (torrent, index, cb) { function startServerFromReadyTorrent (torrent, cb) {
if (server) return if (server) return
// start the streaming torrent-to-http server // start the streaming torrent-to-http server
server = torrent.createServer() server = torrent.createServer()
server.listen(0, function () { server.listen(0, function () {
var port = server.address().port const port = server.address().port
var urlSuffix = ':' + port + '/' + index const urlSuffix = ':' + port
var info = { const info = {
torrentKey: torrent.key, torrentKey: torrent.key,
localURL: 'http://localhost' + urlSuffix, localURL: 'http://localhost' + urlSuffix,
networkURL: 'http://' + networkAddress() + urlSuffix networkURL: 'http://' + networkAddress() + urlSuffix
} }
ipc.send('wt-server-running', info) ipc.send('wt-server-running', info)
ipc.send('wt-server-' + torrent.infoHash, info) // TODO: hack ipc.send('wt-server-' + torrent.infoHash, info)
}) })
} }
@@ -332,23 +335,28 @@ function stopServer () {
} }
function getAudioMetadata (infoHash, index) { function getAudioMetadata (infoHash, index) {
var torrent = client.get(infoHash) const torrent = client.get(infoHash)
var file = torrent.files[index] const file = torrent.files[index]
musicmetadata(file.createReadStream(), function (err, info) { musicmetadata(file.createReadStream(), function (err, info) {
if (err) return if (err) return console.log('error getting audio metadata for ' + infoHash + ':' + index, err)
console.log('got audio metadata for %s: %o', file.name, info) const { artist, album, albumartist, title, year, track, disk, genre } = info
ipc.send('wt-audio-metadata', infoHash, index, info) const importantInfo = { artist, album, albumartist, title, year, track, disk, genre }
console.log('got audio metadata for %s: %o', file.name, importantInfo)
ipc.send('wt-audio-metadata', infoHash, index, importantInfo)
}) })
} }
function selectFiles (torrentOrInfoHash, selections) { function selectFiles (torrentOrInfoHash, selections) {
// Get the torrent object // Get the torrent object
var torrent let torrent
if (typeof torrentOrInfoHash === 'string') { if (typeof torrentOrInfoHash === 'string') {
torrent = client.get(torrentOrInfoHash) torrent = client.get(torrentOrInfoHash)
} else { } else {
torrent = torrentOrInfoHash torrent = torrentOrInfoHash
} }
if (!torrent) {
throw new Error('selectFiles: missing torrent ' + torrentOrInfoHash)
}
// Selections not specified? // Selections not specified?
// Load all files. We still need to replace the default whole-torrent // Load all files. We still need to replace the default whole-torrent
@@ -368,8 +376,8 @@ function selectFiles (torrentOrInfoHash, selections) {
torrent.deselect(0, torrent.pieces.length - 1, false) torrent.deselect(0, torrent.pieces.length - 1, false)
// Add selections (individual files) // Add selections (individual files)
for (var i = 0; i < selections.length; i++) { for (let i = 0; i < selections.length; i++) {
var file = torrent.files[i] const file = torrent.files[i]
if (selections[i]) { if (selections[i]) {
file.select() file.select()
} else { } else {
@@ -382,11 +390,25 @@ function selectFiles (torrentOrInfoHash, selections) {
// Gets a WebTorrent handle by torrentKey // Gets a WebTorrent handle by torrentKey
// Throws an Error if we're not currently torrenting anything w/ that key // Throws an Error if we're not currently torrenting anything w/ that key
function getTorrent (torrentKey) { function getTorrent (torrentKey) {
var ret = client.torrents.find((x) => x.key === torrentKey) const ret = client.torrents.find((x) => x.key === torrentKey)
if (!ret) throw new Error('missing torrent key ' + torrentKey) if (!ret) throw new TorrentKeyNotFoundError(torrentKey)
return ret return ret
} }
function onError (err) { function onError (err) {
console.log(err) console.log(err)
} }
// TODO: remove this once the following bugs are fixed:
// https://bugs.chromium.org/p/chromium/issues/detail?id=490143
// https://github.com/electron/electron/issues/7212
window.testOfflineMode = function () {
console.log('Test, going OFFLINE')
client = window.client = new WebTorrent({
peerId: PEER_ID,
tracker: false,
dht: false,
webSeeds: false
})
listenToClientEvents()
}

0
static/MaterialIcons-Regular.woff2 Normal file → Executable file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -32,6 +32,7 @@
<p> <p>
Version <script>document.write(require('../package.json').version)</script> Version <script>document.write(require('../package.json').version)</script>
(<script>document.write(require('webtorrent/package.json').version)</script>) (<script>document.write(require('webtorrent/package.json').version)</script>)
(<script>document.write(process.arch === 'x64' ? '64-bit' : '32-bit')</script>)
</p> </p>
<p><script>document.write(require('../config').APP_COPYRIGHT)</script></p> <p><script>document.write(require('../config').APP_COPYRIGHT)</script></p>
</body> </body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -19,13 +19,17 @@ body {
overflow: hidden; overflow: hidden;
} }
body { .app {
color: #FFF; color: #FAFAFA; /* grey50 */
font-family: BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif; font-family: BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.5em; line-height: 1.5em;
} }
.app.is-win32 {
font-family: 'Segoe UI', sans-serif;
}
#body { #body {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -71,12 +75,12 @@ table {
height: 100%; height: 100%;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
background: rgb(40, 40, 40); background: rgb(30, 30, 30);
animation: fadein 0.5s; animation: fadein 0.5s;
} }
.app:not(.is-focused) { .app:not(.is-focused) {
background: rgb(50, 50, 50); background: rgb(40, 40, 40);
} }
/* /*
@@ -87,9 +91,7 @@ table {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: local('Material Icons'), src: url('MaterialIcons-Regular.woff2') format('woff2');
local('MaterialIcons-Regular'),
url(MaterialIcons-Regular.woff2) format('woff2');
} }
.icon { .icon {
@@ -122,10 +124,6 @@ table {
* UTILITY CLASSES * UTILITY CLASSES
*/ */
.clickable {
cursor: pointer;
}
.ellipsis { .ellipsis {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -140,33 +138,13 @@ table {
float: right; float: right;
} }
.expand-collapse.expanded::before {
content: '▲'
}
.expand-collapse.collapsed::before {
content: '▼'
}
.expand-collapse::before {
margin-right: 5px;
}
.expand-collapse.collapsed {
display: block;
}
.collapsed {
display: none;
}
/* /*
* HEADER * HEADER
*/ */
.header { .header {
background: rgb(40, 40, 40); background: rgb(40, 40, 40);
border-bottom: 1px solid rgb(20, 20, 20); border-bottom: 1px solid rgb(30, 30, 30);
height: 38px; /* vertically center OS menu buttons (OS X) */ height: 38px; /* vertically center OS menu buttons (OS X) */
padding-top: 7px; padding-top: 7px;
overflow: hidden; overflow: hidden;
@@ -243,7 +221,7 @@ table {
overflow-x: hidden; overflow-x: hidden;
overflow-y: overlay; overflow-y: overlay;
flex: 1 1 auto; flex: 1 1 auto;
margin-top: 37px; margin-top: 38px;
} }
.app.view-player .content { .app.view-player .content {
@@ -287,10 +265,6 @@ table {
font-weight: bold; font-weight: bold;
} }
.open-torrent-address-modal input {
width: 100%;
}
.create-torrent { .create-torrent {
padding: 10px 25px; padding: 10px 25px;
overflow: hidden; overflow: hidden;
@@ -298,6 +272,7 @@ table {
.create-torrent .torrent-attribute { .create-torrent .torrent-attribute {
white-space: nowrap; white-space: nowrap;
margin: 8px 0;
} }
.create-torrent .torrent-attribute>* { .create-torrent .torrent-attribute>* {
@@ -305,13 +280,12 @@ table {
} }
.create-torrent .torrent-attribute label { .create-torrent .torrent-attribute label {
width: 60px; width: 100px;
margin-right: 10px;
vertical-align: top; vertical-align: top;
} }
.create-torrent .torrent-attribute>div { .create-torrent .torrent-attribute>div {
width: calc(100% - 90px); width: calc(100% - 100px);
} }
.create-torrent .torrent-attribute div { .create-torrent .torrent-attribute div {
@@ -320,29 +294,8 @@ table {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.create-torrent .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 textarea.torrent-trackers {
height: 200px;
}
.create-torrent input.torrent-is-private {
margin: 0;
}
/* /*
* BUTTONS * BUTTONS
* See https://www.google.com/design/spec/components/buttons.html
*/ */
a, a,
@@ -356,71 +309,13 @@ i:not(.disabled):hover { /* Show they're clickable without pointer: cursor */
-webkit-filter: brightness(1.3); -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;
margin-left: 10px;
padding: 0;
border: none;
border-radius: 3px;
font-size: 14px;
font-weight: bold;
color: #aaa;
outline: none;
}
button.button-flat {
color: #222;
padding: 7px 18px;
}
button.button-flat.light {
color: #eee;
}
button.button-flat:hover,
button.button-flat:focus { /* Material design: focused */
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;
}
/* /*
* OTHER FORM ELEMENT DEFAULTS * INPUTS
*/ */
input,
input[type='text'] { textarea,
background: transparent; .control {
width: 300px; font-size: 14px !important;
padding: 6px;
border: 1px solid #bbb;
border-radius: 3px;
outline: none;
} }
/* /*
@@ -434,7 +329,7 @@ input[type='text'] {
background-position: center; background-position: center;
transition: -webkit-filter 0.1s ease-out; transition: -webkit-filter 0.1s ease-out;
position: relative; position: relative;
animation: fadein .4s; animation: fadein 0.5s;
} }
.torrent, .torrent,
@@ -443,26 +338,18 @@ input[type='text'] {
} }
.torrent:not(:last-child) { .torrent:not(:last-child) {
border-bottom: 1px solid rgb(20, 20, 20); border-bottom: 1px solid #282828;
}
.torrent:hover {
-webkit-filter: brightness(1.1);
} }
.torrent .metadata { .torrent .metadata {
position: absolute; position: absolute;
top: 25px; top: 20px;
left: 15px; left: 15px;
right: 15px; right: 15px;
width: calc(100% - 40px); width: calc(100% - 30px);
text-shadow: rgba(0, 0, 0, 0.5) 0 0 4px; text-shadow: rgba(0, 0, 0, 0.5) 0 0 4px;
} }
.torrent:hover .metadata {
width: calc(100% - 150px);
}
.torrent .metadata span:not(:last-child)::after { .torrent .metadata span:not(:last-child)::after {
content: ' • '; content: ' • ';
opacity: 0.7; opacity: 0.7;
@@ -470,79 +357,34 @@ input[type='text'] {
padding-right: 4px; padding-right: 4px;
} }
.torrent .buttons { .torrent:hover .metadata {
position: absolute; width: calc(100% - 120px);
top: 29px; }
right: 10px;
align-items: center; .torrent .torrent-controls {
display: none; display: none;
} }
.torrent:hover .buttons { .torrent:hover .torrent-controls {
display: flex; display: block;
} }
.torrent .buttons > * { .torrent .play {
margin-left: 6px; /* space buttons apart */ position: absolute;
top: 23px;
right: 45px;
font-size: 55px;
} }
.torrent .buttons .download { .torrent .delete {
background-color: #2233BB; position: absolute;
width: 28px; top: 38px;
height: 28px; right: 12px;
border-radius: 14px;
font-size: 18px;
padding-top: 6px;
} }
.torrent .buttons .download.downloading { .torrent .download {
color: #44dd44; vertical-align: -0.4em;
} margin-left: -2px;
.torrent .buttons .download.seeding {
color: #44dd44;
}
.torrent .buttons .play {
padding-top: 10px;
background-color: #F44336;
}
.torrent.timeout .play {
padding-top: 8px;
}
.torrent.requested .play,
.loading-spinner {
border-top: 6px solid rgba(255, 255, 255, 0.2);
border-right: 6px solid rgba(255, 255, 255, 0.2);
border-bottom: 6px solid rgba(255, 255, 255, 0.2);
border-left: 6px solid #ffffff;
border-radius: 50%;
color: transparent;
animation: load8 1.1s infinite linear;
}
@keyframes load8 {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.torrent .buttons .play.resume-position {
position: relative;
-webkit-clip-path: circle(18px at center);
}
.torrent .buttons .delete {
opacity: 0.5;
}
.torrent .buttons .delete:hover {
opacity: 0.7;
} }
.torrent .buttons .radial-progress { .torrent .buttons .radial-progress {
@@ -550,9 +392,27 @@ input[type='text'] {
} }
.torrent .name { .torrent .name {
font-size: 18px; font-size: 20px;
font-weight: bold; font-weight: bold;
line-height: 1.5em; line-height: 1.5em;
margin-bottom: 6px;
}
.torrent hr {
position: absolute;
bottom: 5px;
left: calc(50% - 20px);
width: 40px;
height: 1px;
background: #ccc;
display: none;
margin: 0;
padding: 0;
border: none;
}
.torrent:hover hr {
display: block;
} }
/* /*
@@ -606,6 +466,7 @@ body.drag .app::after {
/* /*
* TORRENT LIST: EXPANDED TORRENT DETAILS * TORRENT LIST: EXPANDED TORRENT DETAILS
*/ */
.torrent.selected { .torrent.selected {
height: auto; height: auto;
} }
@@ -629,10 +490,6 @@ body.drag .app::after {
height: 28px; height: 28px;
} }
.torrent-details tr:hover {
background-color: rgba(200, 200, 200, 0.3);
}
.torrent-details td { .torrent-details td {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
@@ -729,11 +586,15 @@ body.drag .app::after {
opacity: 1; opacity: 1;
} }
.player .controls .play-pause { .player .controls .icon.disabled {
opacity: 0.3;
}
.player .controls .icon.skip-previous,
.player .controls .icon.play-pause,
.player .controls .icon.skip-next {
font-size: 28px; font-size: 28px;
margin-top: 5px; margin: 5px;
margin-right: 10px;
margin-left: 15px;
} }
.player .controls .volume-slider { .player .controls .volume-slider {
@@ -775,14 +636,6 @@ body.drag .app::after {
cursor: none; cursor: none;
} }
/* TODO: find better way to handle this (that also
* keeps the header visible too).
*/
.app.hide-video-controls .player .controls:hover {
opacity: 1;
cursor: default;
}
/* invisible click target for scrubbing */ /* invisible click target for scrubbing */
.player .controls .scrub-bar { .player .controls .scrub-bar {
position: absolute; position: absolute;
@@ -917,185 +770,6 @@ video::-webkit-media-text-track-container {
font-weight: bold; font-weight: bold;
} }
/*
* Preferences page, based on Atom settings style
*/
.preferences {
font-size: 12px;
line-height: calc(10/7);
}
.preferences .text {
color: #a8a8a8;
}
.preferences .icon {
color: rgba(170, 170, 170, 0.6);
font-size: 16px;
margin-right: 0.2em;
}
.preferences .icon.enabled {
color: yellow;
}
.preferences .btn {
display: inline-block;
-webkit-appearance: button;
margin: 0;
font-weight: normal;
text-align: center;
vertical-align: middle;
border-color: #cccccc;
border-radius: 3px;
color: #9da5b4;
text-shadow: none;
border: 1px solid #181a1f;
background-color: #3d3d3d;
white-space: initial;
font-size: 0.889em;
line-height: 1;
padding: 0.5em 0.75em;
}
.preferences .btn .icon {
margin: 0;
color: #a8a8a8;
}
.preferences .help .icon {
vertical-align: sub;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .section {
padding: 20px;
border-top: 1px solid #181a1f;
}
.preferences .section:first {
border-top: 0px;
}
.preferences .section:first-child,
.preferences .section:last-child {
padding: 20px;
}
.preferences .section.section:empty {
padding: 0;
border-top: none;
}
.preferences .section-container {
width: 100%;
max-width: 800px;
}
.preferences section .section-heading,
.preferences .section .section-heading {
margin-bottom: 10px;
color: #dcdcdc;
font-size: 1.75em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
cursor: default;
}
.preferences .sub-section-heading.icon:before,
.preferences .section-heading.icon:before {
margin-right: 8px;
}
.preferences .section-heading-count {
margin-left: .5em;
}
.preferences .section-body {
margin-top: 20px;
}
.preferences .sub-section {
margin: 20px 0;
}
.preferences .sub-section .sub-section-heading {
color: #dcdcdc;
font-size: 1.4em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
}
.preferences .preferences-panel label {
color: #a8a8a8;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .preferences-panel .control-group .editor-container {
margin: 0;
}
.preferences .preference-title {
font-size: 1.2em;
-webkit-user-select: none;
display: inline-block;
}
.preferences .preference-description {
color: rgba(170, 170, 170, 0.6);
-webkit-user-select: none;
cursor: default;
}
.preferences input {
font-size: 1.1em;
line-height: 1.15em;
max-height: none;
width: 100%;
padding-left: 0.5em;
border-radius: 3px;
color: #a8a8a8;
border: 1px solid #181a1f;
background-color: #1b1d23;
}
.preferences input::-webkit-input-placeholder {
color: rgba(170, 170, 170, 0.6);
}
.preferences .control-group input {
margin-top: 0.2em;
}
.preferences .control-group input.file-picker-text {
width: calc(100% - 40px);
}
.preferences .control-group .checkbox .icon {
font-size: 1.5em;
margin: 0;
vertical-align: text-bottom;
}
.preferences .checkbox {
width: auto;
}
.checkbox-label {
vertical-align: top;
}
/* /*
* MEDIA OVERLAY / AUDIO DETAILS * MEDIA OVERLAY / AUDIO DETAILS
*/ */
@@ -1254,3 +928,19 @@ video::-webkit-media-text-track-container {
height: 32px; height: 32px;
margin: 4px 0 0 4px; margin: 4px 0 0 4px;
} }
/**
* Use this class on Material UI components to get correct native app behavior:
*
* - Dragging the button should NOT drag the entire app window
* - The cursor should be default, not a pointer (hand) like on the web
*/
.control {
-webkit-app-region: no-drag;
cursor: default !important;
}
.control * {
cursor: default !important;
}

View File

@@ -3,15 +3,11 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebTorrent Desktop</title> <title>Main Window</title>
<link rel="stylesheet" href="main.css"> <link rel="stylesheet" href="main.css">
</head> </head>
<body> <body>
<!-- React prints a warning if you render to <body> directly -->
<div id='body'></div> <div id='body'></div>
<!-- We can't just say src='...main.js', that breaks require()s --> <script>require('../build/renderer/main.js')</script>
<script>
require('../build/renderer/main.js')
</script>
</body> </body>
</html> </html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More