Compare commits

...

402 Commits

Author SHA1 Message Date
Benjamin Tan
e14005abab Add "Paste Torrent Address" button to context menu.
Closes #834.
2016-10-06 18:14:26 +08:00
Benjamin Tan
382ea3d965 Disable "Show in Folder/Finder" and "Remove Data File" for unstarted torrents.
Closes #936.
2016-10-06 18:14:26 +08:00
Benjamin Tan
1b466c64e1 Use a template to build torrent list context menu. 2016-10-06 18:14:17 +08: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
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
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
DC
f259b32cce 0.12.0 2016-08-23 00:07:01 -07:00
DC
eba9aa3e17 Telemetry: log app version 2016-08-22 23:59:52 -07:00
Feross Aboukhadijeh
905eb1611e Merge pull request #807 from feross/dc/fixes
Fix playback + download of default torrents
2016-08-23 06:32:58 +02:00
DC
6d4b8c3c26 Fix playback + download of default torrents
There was a terrible bug introduced in 0809e20a6e -- clicking play on any of the default torrents in a fresh install of the app would fail and result in a 'path missing' error.

This fixes the bug, and also adds a migration step to clean up resulting broken config files
2016-08-22 21:21:32 -07:00
Feross Aboukhadijeh
6a46609cca Merge pull request #804 from feross/dc/fixes
Bugfixes
2016-08-23 01:46:05 +02:00
DC
e872282221 Fix Delete Torrent + Data for newly added magnet links
Before, if you added a magnet link and then tried to delete the torrent plus data before the file list was loaded, it would fail and throw an uncaught error

Fixes #803
2016-08-22 00:58:23 -07:00
DC
24ac5af5b4 Fix jumpToTime
Fixes #801
2016-08-22 00:58:23 -07:00
DC
0ee92fb632 Telemetry: redact stacktraces 2016-08-22 00:54:19 -07:00
Feross Aboukhadijeh
7cbc12c6ff Merge pull request #795 from feross/small-fixes
A bunch of small fixes
2016-08-22 02:04:19 +02:00
Feross Aboukhadijeh
60c82c73cd Merge pull request #793 from feross/debian-system-install
Add system-wide menu item for debian and derivates
2016-08-22 02:03:29 +02:00
Feross Aboukhadijeh
78790e73c7 Merge pull request #788 from feross/content-bounds
Only use setContentBounds for player view
2016-08-22 01:56:14 +02:00
Feross Aboukhadijeh
bf464de16f remove extra console.error
This prevents all errors from being logged twice
2016-08-21 16:55:16 -07:00
Feross Aboukhadijeh
0589963eed code style 2016-08-21 16:54:59 -07:00
Feross Aboukhadijeh
b79971eea5 show video title in webtorrent app for all external players 2016-08-21 16:54:45 -07:00
Feross Aboukhadijeh
d1e557f054 Ignore stdout/stderr from spawned player
This prevents stalling in players like mpv/mplayer for some reason.

I think this could be because of the large number of updates that are
being written to stdout that's filling up a buffer and preventing
playback from continuing.
2016-08-21 16:54:27 -07:00
Feross Aboukhadijeh
93ddb8d638 launch VLC fixes
We can show video title on start now, since we're setting it correctly.

Also, escape the title since it could contain spaces.
2016-08-21 16:53:23 -07:00
Feross Aboukhadijeh
06fdd80845 Merge pull request #792 from feross/dc/telemetry
Telemtry: post at least once a day
2016-08-21 05:48:45 +02:00
grunjol
b0b26f8300 add system-wide launcher and icons for debian and derivates 2016-08-20 23:08:51 -03:00
DC
1db890f5e7 Telemtry: post at least once a day
This ensures that people who keep the app running in the background
for days at a time are still counted as active users.
2016-08-20 18:30:22 -07:00
Feross Aboukhadijeh
0f80f96023 Merge branch 'mathiasvr-external-player' 2016-08-20 01:20:37 -07:00
Feross Aboukhadijeh
2d3673ea33 Fixes to PR #682
- Rename 'playInVlc' preference to 'openExternalPlayer' since we
support more than just VLC now.
- Add default pref options to state.js
2016-08-20 01:19:50 -07:00
Feross Aboukhadijeh
c28260611e Merge pull request #787 from feross/redundant-powersaver
If a power saver block already exists, do nothing
2016-08-20 09:45:00 +02:00
Feross Aboukhadijeh
b5dd00007a Merge pull request #789 from feross/ignore-dot
Ignore '.' argument which is annoying in development
2016-08-20 09:44:52 +02:00
Feross Aboukhadijeh
ac39264f3d Ignore '.' argument which is annoying in development 2016-08-19 22:44:26 -07:00
Feross Aboukhadijeh
667a04a41d Merge branch 'external-player' of https://github.com/mathiasvr/webtorrent-desktop into mathiasvr-external-player
Fixed conflicts in the Preferences page, and added back passing the video title to VLC
2016-08-19 22:06:23 -07:00
Feross Aboukhadijeh
51a9b2ea9b Only use setContentBounds for player view
Fixes: https://github.com/feross/webtorrent-desktop/issues/786
2016-08-19 20:05:52 -07:00
Feross Aboukhadijeh
842ee5ca3c If a power saver block already exists, do nothing
Before this change, when opening the player, both 'onPlayerOpen' and
'onPlayerPlay' would fire, which enabled, disabled, and re-enabled the
power save blocker in quick succession.

Instead, we just want to activate it once.
2016-08-19 20:04:18 -07:00
Feross Aboukhadijeh
2cc67dbda7 AUTHORS 2016-08-19 16:24:47 -07:00
Feross Aboukhadijeh
70bc32614b 0.11.0 2016-08-19 16:24:20 -07:00
Feross Aboukhadijeh
9bf44d7d7e Merge pull request #784 from feross/plist-2
plist@2
2016-08-20 01:21:27 +02:00
Feross Aboukhadijeh
f48ecb87b2 plist@2
Looks like there are no important changes. They just deleted some
deprecated methods we don't use.
2016-08-19 16:19:05 -07:00
Feross Aboukhadijeh
1765aba681 CHANGELOG 2016-08-19 16:15:25 -07:00
Feross Aboukhadijeh
c6063c759e Merge pull request #783 from feross/standard-v8
update to standard v8
2016-08-19 10:46:13 +02:00
Feross Aboukhadijeh
bb4db2cede update to standard v8
The only thing we have to change is to self-close tags that don't
contain anything. This wasn't even an explicit change in standard. It
was already a rule, but I think it wasn't getting enforced very well
until a bugfix.
2016-08-19 01:44:28 -07:00
DC
7c36898f78 Merge pull request #674 from codealchemist/open-in-vlc-preferences
Added Playback preferences and Play in VLC
2016-08-19 00:49:37 -07:00
Alberto Miranda
23e8cdf216 using key on rendered checkbox to avoid react errors; dispatching updatePreferences to update playInVlc. 2016-08-19 01:10:55 -03:00
Alberto Miranda
5ffd4123a1 fixed merge conflicts 2016-08-19 01:09:43 -03:00
Alberto Miranda
27e3c14f10 merged 2016-08-19 00:28:07 -03:00
Feross Aboukhadijeh
d57bfb825a Merge pull request #776 from feross/dc/missing-path
Check for missing download path
2016-08-15 01:10:05 +02:00
DC
0809e20a6e Check path for each torrent 2016-08-13 22:37:14 -07:00
DC
1ec305162e Check for missing download path
Fixes #646
2016-08-12 20:57:03 -07:00
Feross Aboukhadijeh
45d46d7ae8 show title on 'create new torrent' page 2016-08-12 15:35:56 -07:00
Feross Aboukhadijeh
adb41736d5 Merge pull request #775 from feross/dc/768
Create Torrent: make trackers editable again
2016-08-13 00:08:38 +02:00
DC
09d6fa550a Handle Save Torrent File As... -> Cancel 2016-08-12 09:31:28 -07:00
DC
75cc7383cb Create Torrent: make trackers editable again
Fixes #768
2016-08-12 09:21:12 -07:00
DC
4d48b9e7c1 Fix screen stacking bug
You can no longer open a whole stack of Prefs windows, or Create Torrent windows

Simplifies and fixes behavior when dropping files onto the app or the dock icon. Before, you could use drag-drop to create stacks of Create Torrent windows. Now, you can only create torrents from the home screen.

Fixes #665
2016-08-12 09:03:32 -07:00
Feross Aboukhadijeh
563e1ca0ba Support for instant.io links does not belong in webtorrent core 2016-08-11 00:20:07 -07:00
Feross Aboukhadijeh
0fa3b678b0 Merge pull request #772 from feross/electron-1.3.3
Update Electron to 1.3.3
2016-08-11 06:54:51 +02:00
Feross Aboukhadijeh
8420c65d25 Merge pull request #771 from feross/dc/file-handler
Pref: default torrent file handler
2016-08-11 02:27:54 +02:00
Feross Aboukhadijeh
3232e96f6e Resize the window's content area
Fixes: https://github.com/feross/webtorrent-desktop/issues/565

This was trivial thanks to a new Electron API in 1.3.3
2016-08-10 16:48:32 -07:00
Feross Aboukhadijeh
110e25af73 electron-prebuilt@1.3.3 2016-08-10 16:47:58 -07:00
DC
8233faf518 Pref: default torrent file handler
Before, the app made itself the default torrent file handler automatically, pissing off some of our users. Now, it's not by default, and you can change it in the prefs.
2016-08-10 02:23:08 -07:00
Feross Aboukhadijeh
11eb603930 0.10.0 2016-08-05 22:53:49 -07:00
Feross Aboukhadijeh
1d55c51a16 Improve AUTHORS.md rendering 2016-08-05 22:41:37 -07:00
Feross Aboukhadijeh
447a7e514e Merge pull request #764 from feross/smoke-tests
Add Smoke Tests to CONTRIBUTING.md
2016-08-05 22:27:37 -07:00
Feross Aboukhadijeh
fd433784bd Add Smoke Tests to CONTRIBUTING.md
Fix #582
2016-08-05 22:26:33 -07:00
Feross Aboukhadijeh
4e2b196b26 CHANGELOG 2016-08-05 22:06:58 -07:00
Feross Aboukhadijeh
14fcbfcced make logs consistent 2016-08-05 21:44:32 -07:00
Feross Aboukhadijeh
4126d15821 Move .babelrc file into src/ 2016-08-05 21:41:22 -07:00
Feross Aboukhadijeh
0d1cc72798 Merge pull request #760 from feross/babel
Replace deprecated `react-tools` with `babel`
2016-08-05 16:47:10 -07:00
Feross Aboukhadijeh
c42eb789df fix typo 2016-08-05 16:37:25 -07:00
Feross Aboukhadijeh
c1dd0b31cf package: Ignore src/ directory since only build/ is used
@dcposch -- does this look reasonable?
2016-08-04 21:59:01 -07:00
Feross Aboukhadijeh
9afed7fb1b Remove --dev flag, Run React in production mode when Electron is in production mode 2016-08-04 21:58:34 -07:00
Feross Aboukhadijeh
a8239895c6 babel: Add --quiet option 2016-08-04 21:41:37 -07:00
Feross Aboukhadijeh
7531ab4623 Simplify babel integration further
The "react" preset is composed of a bunch of plugins.
https://babeljs.io/docs/plugins/preset-react/

Turns out, we only need 2 of them, not all 5.
2016-08-04 21:37:53 -07:00
Feross Aboukhadijeh
9b36f9cb22 Ensure that build folder gets generated before npm publish
So users using `npm install -g webtorrent-desktop` will always get a
working version.
2016-08-04 21:05:03 -07:00
Feross Aboukhadijeh
29f8ef6b72 Replace deprecated react-tools with babel
- Switch to babel, since react-tools has been deprecated since June 12,
2015. See
https://facebook.github.io/react/blog/2015/06/12/deprecating-jstransform
-and-react-tools.html

- Move babel command to "npm run build"

- Move commands for package into "bin/package.js"
2016-08-04 21:04:49 -07:00
Feross Aboukhadijeh
7752e41416 Merge pull request #757 from feross/fix-695
Fix "Cannot read property 'numPiecesPresent' of undefined"
2016-08-04 15:03:16 -07:00
Feross Aboukhadijeh
756ccd1921 Merge pull request #758 from feross/fix-284
Allow dragging magnet links (Fix #284)
2016-08-04 15:02:32 -07:00
Alberto Miranda
39ae0343fc Merge branch 'master' of https://github.com/feross/webtorrent-desktop 2016-08-04 08:10:31 -03:00
Feross Aboukhadijeh
67409214a4 Allow dragging magnet links (Fix #284) 2016-08-03 20:26:46 -07:00
Feross Aboukhadijeh
b3d0edfec1 Merge pull request #756 from feross/electron-1.3.2
electron-prebuilt@1.3.2
2016-08-03 17:06:35 -07:00
Feross Aboukhadijeh
87b9dba568 Fix "Cannot read property 'numPiecesPresent' of undefined"
Fixes #695
2016-08-03 17:06:04 -07:00
Feross Aboukhadijeh
91a4c0cff5 electron-prebuilt@1.3.2
Changelog: https://github.com/electron/electron/releases/tag/v1.3.2

Nothing in the changelog fixes known WebTorrent Desktop issues.
2016-08-02 19:11:06 -07:00
Feross Aboukhadijeh
9670dc7a81 Merge pull request #752 from feross/user-tasks
Add User Tasks for Windows.
2016-08-02 18:57:44 -07:00
Feross Aboukhadijeh
4f2c5b946d Merge pull request #753 from feross/rimraf
Use `rimraf` instead of `rm -rf` for Windows.
2016-08-02 18:52:35 -07:00
Benjamin Tan
fc53c68dd9 Use rimraf instead of rm -rf for Windows. 2016-07-31 16:36:43 +08:00
Benjamin Tan
03bc4cf9b1 Add User Tasks for Windows.
Closes #114.
2016-07-31 16:29:38 +08:00
Alberto Miranda
c637878603 removed logging; restored minimist in package.js ignore list. 2016-07-29 23:01:17 -03:00
Alberto Miranda
91e61f6cd4 using icon as checkbox 2016-07-29 22:07:44 -03:00
Alberto Miranda
9f66418073 merged with latest master; using icon as checkbox. 2016-07-29 22:07:25 -03:00
Alberto Miranda
2c3d667675 Merge remote-tracking branch 'feross/master' 2016-07-29 20:49:29 -03:00
Feross Aboukhadijeh
2ab93f2309 Merge branch 'location-history' 2016-07-28 22:00:58 -07:00
Feross Aboukhadijeh
05ce20303c fix exception 2016-07-28 20:35:36 -07:00
Feross Aboukhadijeh
5e997d1bbf Merge pull request #748 from feross/location-history
Switch to using `location-history` package
2016-07-28 20:29:55 -07:00
Feross Aboukhadijeh
d95e5b02d6 Remove extra 'resetTitle' call 2016-07-28 20:10:48 -07:00
Feross Aboukhadijeh
536f04985f Switch to using location-history package
https://npmjs.com/package/location-history
2016-07-28 20:07:12 -07:00
Feross Aboukhadijeh
43a81f725f Merge pull request #747 from feross/electron-1.3.1
electron-prebuilt@1.3.1
2016-07-27 16:05:34 -07:00
Feross Aboukhadijeh
8373e69d09 Merge pull request #746 from feross/fix-700
Fix 700
2016-07-27 16:04:29 -07:00
Feross Aboukhadijeh
c5ed5fabd8 Fix typo based on @mathiasvr's feedback 2016-07-27 15:51:19 -07:00
Feross Aboukhadijeh
8ba4dadb10 electron-prebuilt@1.3.1 2016-07-27 14:49:59 -07:00
Feross Aboukhadijeh
112600f5c3 Set video title when opening VLC
Fix #700

The title is set with the `--meta-title` flag to VLC.
2016-07-27 14:46:39 -07:00
Feross Aboukhadijeh
c2f869b362 Use dispatch('setTitle') and add dispatch('resetTitle') 2016-07-27 14:39:22 -07:00
Feross Aboukhadijeh
2590e0effc Merge pull request #743 from feross/f/mac
OS X -> Mac
2016-07-27 13:15:48 -07:00
Feross Aboukhadijeh
b417ef5b03 Merge pull request #744 from feross/f/engines
Add "engines" field to package.json
2016-07-27 13:15:12 -07:00
Feross Aboukhadijeh
1733a506c0 Merge pull request #742 from feross/peerid
Set peer ID to start with "-WD-"
2016-07-27 13:14:54 -07:00
Feross Aboukhadijeh
ac05cc4387 Use arrow function
cc @mathiasvr
2016-07-27 12:37:58 -07:00
Feross Aboukhadijeh
904f337713 Add "engines" field to package.json
Fixes #675
2016-07-26 22:58:07 -07:00
Feross Aboukhadijeh
febad56497 OS X -> Mac 2016-07-26 21:55:05 -07:00
Feross Aboukhadijeh
cb71de2313 Set peer ID to start with "-WD-"
To distinguish WebTorrent Desktop (WD) from WebTorrent in the browser
(WW).

See the spec:

http://www.bittorrent.org/beps/bep_0020.html
https://wiki.theory.org/BitTorrentSpecification
2016-07-26 20:20:01 -07:00
Feross Aboukhadijeh
6891ef1a0d fix paths in clean script 2016-07-26 16:13:51 -07:00
Mathias Rasmussen
6c68645b0f Custom external media player 2016-07-26 23:57:33 +02:00
Feross Aboukhadijeh
767ca71f7d Update README.md 2016-07-26 01:33:24 -07:00
Feross Aboukhadijeh
1605d23509 Merge pull request #740 from feross/electron-1.3.0
electron-prebuilt@1.3.0
2016-07-25 16:42:08 -07:00
Feross Aboukhadijeh
90a0201e38 Merge pull request #739 from feross/fixes-738
Electron: Updates for Electron 1.2.8
2016-07-25 16:41:54 -07:00
Feross Aboukhadijeh
80983c2058 electron-prebuilt@1.3.0
Another Electron was just released. Let's bump from 1.2.8 to 1.3.0.

Changelog:

- Upgrade to Chrome 52
- Update to Node.js 6.3.0
2016-07-25 15:42:55 -07:00
Feross Aboukhadijeh
ad62bbd9d3 Linux: Support showing badge count
This was a macOS-only API before, but it's cross-platform now via
`app.setBadgeCount()`
2016-07-25 15:37:29 -07:00
Feross Aboukhadijeh
1b3b6fef10 Electron: Use 'quit' role 2016-07-25 15:30:02 -07:00
Feross Aboukhadijeh
6e1ff18eb9 macOS: add missing Edit menu roles 2016-07-25 15:29:53 -07:00
Feross Aboukhadijeh
5796ba32a6 Electron: Use default labels and accelerators
Less code for us to maintain.

This also gives us free internationalization in a future Electron
version (they'll set the label dynamically based on the 'role')

One slight regression with this change, but it will be fixed in a
future Electron once this PR is merged:
https://github.com/electron/electron/pull/6600
2016-07-25 15:21:46 -07:00
Feross Aboukhadijeh
2eb33e5f0c Merge pull request #738 from mathiasvr/bump
bump versions of electron and fs-extra
2016-07-25 14:30:17 -07:00
Mathias Rasmussen
7c14d8c909 bump versions of electron and fs-extra 2016-07-25 02:18:14 +02:00
Feross Aboukhadijeh
fe4c1b0ee8 Merge pull request #733 from feross/f/application-config
application-config@1
2016-07-23 02:35:43 -07:00
DC
d21396c618 React: make webtorrent process easier to debug
Add window.client = the WebTorrent client
2016-07-22 22:14:51 -07:00
DC
9df51aec49 React: clean up App component 2016-07-22 21:55:31 -07:00
DC
734b0731a1 React: address PR comments 2016-07-22 21:48:38 -07:00
DC
d20786cd69 React: fix package script 2016-07-22 19:57:06 -07:00
DC
793ea79cab Perf: remove DL button animation
Turns out this is huge. For some inexplicable reason, it improves hover and scroll in the torrent detail view, for a torrent with 350 files, from ~10FPS to ~60FPS.
2016-07-22 17:52:22 -07:00
Feross Aboukhadijeh
073a86ecbd application-config@1
This protects against corrupting the configuration file if the
application crashes before the stream finishes writing to the file

Especially important for large configuration files

See https://github.com/LinusU/node-application-config/pull/3
2016-07-22 17:21:25 -07:00
DC
7b4fd57a94 Perf: don't update torrent progres >1x per second 2016-07-22 16:38:42 -07:00
DC
f86ca0a168 Perf: use px, not em for column widths 2016-07-22 16:23:11 -07:00
DC
0d3c18d3bc Don't show padding files 2016-07-22 13:06:58 -07:00
DC
a4a31d0860 React: fix warnings 2016-07-22 13:06:58 -07:00
DC
946bba19a9 React: convert functions to controls 2016-07-22 13:06:58 -07:00
DC
2a1e987d42 Switch from virtualdom to React 2016-07-22 13:06:55 -07:00
DC
fbcf718440 Perf: skip duplicate update()s, measure app render time 2016-07-22 13:05:17 -07:00
DC
18aadf9d23 Merge v0.9.0 (#730)
v0.9.0
2016-07-21 15:52:54 -07:00
Andrea Tupini
cd575d2005 Display torrent ETA in list item (#726)
* Display torrent ETA in list

ETA being the estimated time to download completion calculated using the
current download speed and missing bytes.

* Refactor ETA string construction

* Removed extra 's' in ETA string construction
2016-07-21 13:18:56 -07:00
Alberto Miranda
dc7ccb3956 Merge remote-tracking branch 'feross/master' 2016-07-20 09:10:55 -03:00
Alberto Miranda
a420936657 Merge branch 'master' into open-in-vlc-preferences 2016-07-19 02:11:13 -03:00
Alberto Miranda
dcab7f72d4 fixed error with minimist not being loaded on build 2016-07-19 02:10:37 -03:00
Alberto Miranda
a695f7c2d7 using this.state 2016-07-17 21:05:24 -03:00
Alberto Miranda
7677bff6d4 merged with latest master 2016-07-17 20:57:06 -03:00
Alberto Miranda
c7626997de merged with latest master 2016-07-17 20:48:25 -03:00
Alberto Miranda
91a1ab4a56 removed unused config property 2016-06-27 08:21:30 -03:00
Alberto Miranda
3e19cdfb0b fixed js style 2016-06-25 17:40:47 -03:00
Alberto Miranda
2043dc2161 added Playback preferences; added Play in VLC preference. 2016-06-25 17:36:50 -03:00
Alberto Miranda
a9e36472c5 fixed js style 2016-06-24 09:38:08 -03:00
Alberto Miranda
4df4f9b2ad fixed js style 2016-06-24 09:34:54 -03:00
Alberto Miranda
4ad55173a5 added missing method to menu.js 2016-06-24 09:34:12 -03:00
Alberto Miranda
b9c82dd6b2 persisting and reloading "Open in VLC" menu item state. 2016-06-24 09:28:28 -03:00
Alberto Miranda
8333f4893f fixed js style 2016-06-24 01:02:33 -03:00
Alberto Miranda
f071965ae8 fixed js style 2016-06-24 00:57:33 -03:00
Alberto Miranda
a4fa9ac666 added open in vlc feature. 2016-06-24 00:46:42 -03:00
Alberto Miranda
939ee555b7 fixed typo in buttonIcons (dnla instead of dlna). 2016-06-23 23:45:54 -03:00
191 changed files with 6880 additions and 4865 deletions

6
.gitignore vendored
View File

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

View File

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

View File

@@ -2,29 +2,37 @@
#### Ordered by first contribution.
- Feross Aboukhadijeh <feross@feross.org>
- DC <dcposch@dcpos.ch>
- Nate Goldman <nate@ngoldman.me>
- Chris Morris <chris@chrismorris.org>
- Giuseppe Crinò <giuscri@gmail.com>
- Romain Beaumont <romain.rom1@gmail.com>
- Dan Flettre <fletd01@yahoo.com>
- Liam Gray <liam.r.gray@gmail.com>
- grunjol <grunjol@users.noreply.github.com>
- Rémi Jouannet <remijouannet@users.noreply.github.com>
- Evan Miller <miller.evan815@gmail.com>
- Alex <alxmorais8@msn.com>
- Diego Rodríguez Baquero <diegorbaquero@gmail.com>
- Karlo Luis Martinez Martos <karlo.luis.m@gmail.com>
- gabriel <furstenheim@gmail.com>
- Rolando Guedes <rolando.guedes@3gnt.net>
- Benjamin Tan <demoneaux@gmail.com>
- Mathias Rasmussen <mathiasvr@gmail.com>
- Sergey Bargamon <sergey@bargamon.ru>
- Thomas Watson Steen <w@tson.dk>
- anonymlol <anonymlol7@gmail.com>
- Gediminas Petrikas <gedas18@gmail.com>
- Adam Gotlib <gotlib.adam+dev@gmail.com>
- Rémi Jouannet <remijouannet@gmail.com>
- Feross Aboukhadijeh (feross@feross.org)
- DC (dcposch@dcpos.ch)
- Nate Goldman (nate@ngoldman.me)
- Chris Morris (chris@chrismorris.org)
- Giuseppe Crinò (giuscri@gmail.com)
- Romain Beaumont (romain.rom1@gmail.com)
- Dan Flettre (fletd01@yahoo.com)
- Liam Gray (liam.r.gray@gmail.com)
- Rémi Jouannet (remijouannet@users.noreply.github.com)
- Evan Miller (miller.evan815@gmail.com)
- Alex (alxmorais8@msn.com)
- Diego Rodríguez Baquero (diegorbaquero@gmail.com)
- Karlo Luis Martinez Martos (karlo.luis.m@gmail.com)
- gabriel (furstenheim@gmail.com)
- Rolando Guedes (rolando.guedes@3gnt.net)
- Benjamin Tan (demoneaux@gmail.com)
- Mathias Rasmussen (mathiasvr@gmail.com)
- Sergey Bargamon (sergey@bargamon.ru)
- Thomas Watson Steen (w@tson.dk)
- anonymlol (anonymlol7@gmail.com)
- Gediminas Petrikas (gedas18@gmail.com)
- Alberto Miranda (codealchemist@gmail.com)
- Adam Gotlib (gotlib.adam+dev@gmail.com)
- Rémi Jouannet (remijouannet@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)
#### Generated by bin/update-authors.sh.

View File

@@ -1,17 +1,184 @@
# WebTorrent Desktop Version History
## 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/feross/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
### Added
- New Preference to "Set WebTorrent as default handler for torrents and magnet links" (#771)
- New Preference to "Always play in VLC" (#674)
- Check for missing default download path and torrent folders on start up (#776)
### Changed
- Do not automatically set WebTorrent as the default handler for torrents (#771)
- Torrents can only be created from the home screen (#770)
- Update Electron to 1.3.3 (#772)
### Fixed
- Allow modifying the default tracker list on the Create Torrent page (#775)
- Prevent opening multiple stacked Preference windows or Create Torrent windows (#770)
- Windows: Player window auto-resize does not match video aspect ratio (#565)
- Missing page title on Create Torrent page
## v0.10.0 - 2016-08-05
### Added
- Drag-and-drop magnet links (selected text) is now supported (#284)
- Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114)
- Linux: Show badge count for completed torrent downloads
### Changed
- 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)
- The primary bottleneck was actually `hyperx`, not `virtual-dom`.
- Update Electron to 1.3.2 (#738) (#739) (#740) (#747) (#756)
- Mac 10.9: Fix the fullscreen button showing
- Mac 10.9: Fix window having border
- Mac 10.9: Fix occasional crash
- Mac: Update Squirrel.Mac to 0.2.1 (fixes situations in which updates would not get applied)
- Mac: Fix window not showing in Window menu
- Mac: Fix context menu always choosing first item by default
- Linux: Fix startup crashes (some Linux distros)
- Linux: Fix menubar not hiding after entering fullscreen (some Linux distros)
- Improved location history (back/forward buttons) to fix rare exceptions (#687) (#748)
- Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history)
### Fixed
- When streaming to VLC, set VLC window title to torrent file name (#746)
- Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695)
- Fix rare case where config file could not be completely written (#733)
## v0.9.0 - 2016-07-20
### Added
- Save selected subtitles
- Ask for confirmation before deleting torrents
- Support Debian Jessie
### Changed
- Only send telemetry in production
- Clean up the code. Split main.js, refactor lots of things
### Fixed
- Fix state.playing.jumpToTime behavior
- Remove torrent file and poster image when deleting a torrent

View File

@@ -73,3 +73,23 @@ By making a contribution to this project, I certify that:
record of the contribution (including all personal information I submit with it,
including my sign-off) is maintained indefinitely and may be redistributed consistent
with this project or the open source license(s) involved.
## Smoke Tests
Before a release, check that the following basic use cases work correctly:
1. Click "Play" to stream a built-in torrent (e.g. Sintel)
- Ensure that seeking to undownloaded region works and plays immediately.
- Ensure that sintel.mp4 gets downloaded to `~/Downloads`.
2. Check that the auto-updater works
- Open the console and check for the line "No update available" to indicate
3. Add a new .torrent file via drag-and-drop.
- Ensure that it gets added to the list and starts downloading
4. Remove a torrent from the client
- Ensure that the file is removed from `~/Downloads`
5. Create and seed a new a torrent via drag-and-drop.
- Ensure that the torrent gets created and seeding begins.

View File

@@ -7,7 +7,7 @@
<br>
</h1>
<h4 align="center">The streaming torrent client. For OS X, Windows, and Linux.</h4>
<h4 align="center">The streaming torrent app. For Mac, Windows, and Linux.</h4>
<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>
@@ -17,31 +17,74 @@
## 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/feross/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">
<img src="https://webtorrent.io/img/screenshot-main.png" width="562" height="630" alt="screenshot" 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">
</p>
## How to Contribute
### Install dependencies
### Get the code
```
$ git clone https://github.com/feross/webtorrent-desktop.git
$ cd webtorrent-desktop
$ npm install
```
### Run app
### Run the app
```
$ npm start
```
### Package app
### Watch the code
Builds app binaries for OS X, Linux, and Windows.
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! Ses 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.
```
$ npm run package
@@ -50,30 +93,34 @@ $ npm run package
To build for one platform:
```
$ npm run package -- [platform]
$ npm run package -- [platform] [options]
```
Where `[platform]` is `darwin`, `linux`, `win32`, or `all` (default).
The following optional arguments are available:
- `--sign` - Sign the application (OS X, Windows)
- `--sign` - Sign the application (Mac, Windows)
- `--package=[type]` - Package single output type.
- `deb` - Debian package
- `zip` - Linux zip file
- `dmg` - OS X disk image
- `dmg` - Mac disk image
- `exe` - Windows installer
- `portable` - Windows portable app
- `all` - All platforms (default)
Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, *-darwin.zip for OS X) will always be produced.
Note: Even with the `--package` option, the auto-update files (.nupkg for Windows,
*-darwin.zip for Mac) will always be produced.
#### Windows build notes
To package the Windows app from non-Windows platforms, [Wine](https://www.winehq.org/) needs
to be installed.
The Windows app can be packaged from **any** platform.
On OS X, 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
@@ -81,11 +128,22 @@ brew install wine
(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
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

View File

@@ -1,99 +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']
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,17 +5,23 @@
* Useful for developers.
*/
var fs = require('fs')
var os = require('os')
var path = require('path')
var rimraf = require('rimraf')
const fs = require('fs')
const os = require('os')
const path = require('path')
const rimraf = require('rimraf')
var config = require('../config')
var handlers = require('../main/handlers')
const config = require('../src/config')
const handlers = require('../src/main/handlers')
// First, remove generated files
rimraf.sync('build/')
rimraf.sync('dist/')
// Remove any saved configuration
rimraf.sync(config.CONFIG_PATH)
var tmpPath
// Remove any temporary files
let tmpPath
try {
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
} catch (err) {

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('" *.js bin/ main/ renderer/ -R |
grep '.js:' |
sed "s/.*require('\([^'\/]*\).*/\1/" |
grep -v '^\.' |
sort |
uniq

View File

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

View File

@@ -1,27 +1,28 @@
#!/usr/bin/env node
/**
* Builds app binaries for OS X, Linux, and Windows.
* Builds app binaries for Mac, Windows, and Linux.
*/
var cp = require('child_process')
var electronPackager = require('electron-packager')
var fs = require('fs')
var minimist = require('minimist')
var mkdirp = require('mkdirp')
var os = require('os')
var path = require('path')
var rimraf = require('rimraf')
var series = require('run-series')
var zip = require('cross-zip')
const cp = require('child_process')
const electronPackager = require('electron-packager')
const fs = require('fs')
const minimist = require('minimist')
const mkdirp = require('mkdirp')
const os = require('os')
const path = require('path')
const rimraf = require('rimraf')
const series = require('run-series')
const zip = require('cross-zip')
var config = require('../config')
var pkg = require('../package.json')
const config = require('../src/config')
const pkg = require('../package.json')
var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
var DIST_PATH = path.join(config.ROOT_PATH, 'dist')
const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
const BUILD_PATH = path.join(config.ROOT_PATH, 'build')
const DIST_PATH = path.join(config.ROOT_PATH, 'dist')
var argv = minimist(process.argv.slice(2), {
const argv = minimist(process.argv.slice(2), {
boolean: [
'sign'
],
@@ -36,7 +37,13 @@ var argv = minimist(process.argv.slice(2), {
function build () {
rimraf.sync(DIST_PATH)
var platform = argv._[0]
rimraf.sync(BUILD_PATH)
console.log('Build: Transpiling to ES5...')
cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' })
console.log('Build: Transpiled to ES5.')
const platform = argv._[0]
if (platform === 'darwin') {
buildDarwin(printDone)
} else if (platform === 'win32') {
@@ -54,26 +61,26 @@ function build () {
}
}
var all = {
const all = {
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
// property on Windows, and `NSHumanReadableCopyright` on OS X.
// property on Windows, and `NSHumanReadableCopyright` on Mac.
'app-copyright': config.APP_COPYRIGHT,
// The release version of the application. Maps to the `ProductVersion` metadata
// property on Windows, and `CFBundleShortVersionString` on OS X.
// property on Windows, and `CFBundleShortVersionString` on Mac.
'app-version': pkg.version,
// Package the application's source code into an archive, using Electron's archive
// format. Mitigates issues around long path names on Windows and slightly speeds up
// require().
asar: true,
// A glob expression, that unpacks the files with matching names to the
// "app.asar.unpacked" directory.
'asar-unpack': 'WebTorrent*',
asar: {
// A glob expression, that unpacks the files with matching names to the
// "app.asar.unpacked" directory.
unpack: 'WebTorrent*'
},
// The build version of the application. Maps to the FileVersion metadata property on
// Windows, and CFBundleVersion on OS X. Note: Windows requires the build version to
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
// start with a number. We're using the version of the underlying WebTorrent library.
'build-version': require('webtorrent/package.json').version,
@@ -82,7 +89,7 @@ var all = {
// Pattern which specifies which files to ignore when copying files to create the
// package(s).
ignore: /^\/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.
name: config.APP_NAME,
@@ -98,39 +105,39 @@ var all = {
prune: true,
// The Electron version with which the app is built (without the leading 'v')
version: require('electron-prebuilt/package.json').version
version: require('electron/package.json').version
}
var darwin = {
// Build for OS X
const darwin = {
// Build for Mac
platform: 'darwin',
// Build 64 bit binaries only.
// Build x64 binaries only.
arch: 'x64',
// The bundle identifier to use in the application's plist (OS X only).
// The bundle identifier to use in the application's plist (Mac only).
'app-bundle-id': 'io.webtorrent.webtorrent',
// The application category type, as shown in the Finder via "View" -> "Arrange by
// Application Category" when viewing the Applications directory (OS X only).
// Application Category" when viewing the Applications directory (Mac only).
'app-category-type': 'public.app-category.utilities',
// The bundle identifier to use in the application helper's plist (OS X only).
// The bundle identifier to use in the application helper's plist (Mac only).
'helper-bundle-id': 'io.webtorrent.webtorrent-helper',
// Application icon.
icon: config.APP_ICON + '.icns'
}
var win32 = {
const win32 = {
// Build for Windows.
platform: 'win32',
// Build 32 bit binaries only.
arch: 'ia32',
// Build ia32 and x64 binaries.
arch: ['ia32', 'x64'],
// Object hash of application metadata to embed into the executable (Windows only)
'version-string': {
win32metadata: {
// Company that produced the file.
CompanyName: config.APP_NAME,
@@ -156,12 +163,12 @@ var win32 = {
icon: config.APP_ICON + '.ico'
}
var linux = {
const linux = {
// Build for Linux.
platform: 'linux',
// Build 32 and 64 bit binaries.
arch: 'all'
// Build ia32 and x64 binaries.
arch: ['ia32', 'x64']
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
}
@@ -169,18 +176,18 @@ var linux = {
build()
function buildDarwin (cb) {
var plist = require('plist')
const plist = require('plist')
console.log('OS X: Packaging electron...')
console.log('Mac: Packaging electron...')
electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
if (err) return cb(err)
console.log('OS X: Packaged electron. ' + buildPath)
console.log('Mac: Packaged electron. ' + buildPath)
var appPath = path.join(buildPath[0], config.APP_NAME + '.app')
var contentsPath = path.join(appPath, 'Contents')
var resourcesPath = path.join(contentsPath, 'Resources')
var infoPlistPath = path.join(contentsPath, 'Info.plist')
var infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
const appPath = path.join(buildPath[0], config.APP_NAME + '.app')
const contentsPath = path.join(appPath, 'Contents')
const resourcesPath = path.join(contentsPath, 'Resources')
const infoPlistPath = path.join(contentsPath, 'Info.plist')
const infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
infoPlist.CFBundleDocumentTypes = [
{
@@ -254,31 +261,31 @@ function buildDarwin (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:
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
* the same author as the current version.
* - So users will not a see a warning about the app coming from an "Unidentified
* Developer" when they open it for the first time (OS X Gatekeeper).
* Developer" when they open it for the first time (Mac Gatekeeper).
*
* To sign an OS X app for distribution outside the App Store, the following are
* To sign an Mac app for distribution outside the App Store, the following are
* required:
* - Xcode
* - Xcode Command Line Tools (xcode-select --install)
* - Membership in the Apple Developer Program
*/
var signOpts = {
const signOpts = {
app: appPath,
platform: 'darwin',
verbose: true
}
console.log('OS X: Signing app...')
console.log('Mac: Signing app...')
sign(signOpts, function (err) {
if (err) return cb(err)
console.log('OS X: Signed app.')
console.log('Mac: Signed app.')
cb(null)
})
}
@@ -293,25 +300,25 @@ function buildDarwin (cb) {
function packageZip () {
// Create .zip file (used by the auto-updater)
console.log('OS X: Creating zip...')
console.log('Mac: Creating zip...')
var inPath = path.join(buildPath[0], config.APP_NAME + '.app')
var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
const inPath = path.join(buildPath[0], config.APP_NAME + '.app')
const outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
zip.zipSync(inPath, outPath)
console.log('OS X: Created zip.')
console.log('Mac: Created zip.')
}
function packageDmg (cb) {
console.log('OS X: Creating dmg...')
console.log('Mac: Creating dmg...')
var appDmg = require('appdmg')
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)
// Create a .dmg (OS X disk image) file, for easy user installation.
var dmgOpts = {
// Create a .dmg (Mac disk image) file, for easy user installation.
const dmgOpts = {
basepath: config.ROOT_PATH,
target: targetPath,
specification: {
@@ -332,13 +339,13 @@ function buildDarwin (cb) {
}
}
var dmg = appDmg(dmgOpts)
const dmg = appDmg(dmgOpts)
dmg.once('error', cb)
dmg.on('progress', function (info) {
if (info.type === 'step-begin') console.log(info.title + '...')
})
dmg.once('finish', function (info) {
console.log('OS X: Created dmg.')
console.log('Mac: Created dmg.')
cb(null)
})
}
@@ -346,7 +353,7 @@ function buildDarwin (cb) {
}
function buildWin32 (cb) {
var installer = require('electron-winstaller')
const installer = require('electron-winstaller')
console.log('Windows: Packaging electron...')
/*
@@ -354,7 +361,7 @@ function buildWin32 (cb) {
* - Windows Authenticode private key and cert (authenticode.p12)
* - Windows Authenticode password file (authenticode.txt)
*/
var CERT_PATH
let CERT_PATH
try {
fs.accessSync('D:')
CERT_PATH = 'D:'
@@ -366,12 +373,12 @@ function buildWin32 (cb) {
if (err) return cb(err)
console.log('Windows: Packaged electron. ' + buildPath)
var signWithParams
let signWithParams
if (process.platform === 'win32') {
if (argv.sign) {
var certificateFile = path.join(CERT_PATH, 'authenticode.p12')
var certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
var timestampServer = 'http://timestamp.comodoca.com'
const certificateFile = path.join(CERT_PATH, 'authenticode.p12')
const certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
const timestampServer = 'http://timestamp.comodoca.com'
signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
} else {
printWarning()
@@ -380,20 +387,26 @@ function buildWin32 (cb) {
printWarning()
}
var tasks = []
if (argv.package === 'exe' || argv.package === 'all') {
tasks.push((cb) => packageInstaller(cb))
}
if (argv.package === 'portable' || argv.package === 'all') {
tasks.push((cb) => packagePortable(cb))
}
const tasks = []
buildPath.forEach(function (filesPath) {
const destArch = filesPath.split('-').pop()
if (argv.package === 'exe' || argv.package === 'all') {
tasks.push((cb) => packageInstaller(filesPath, destArch, cb))
}
if (argv.package === 'portable' || argv.package === 'all') {
tasks.push((cb) => packagePortable(filesPath, destArch, cb))
}
})
series(tasks, cb)
function packageInstaller (cb) {
console.log('Windows: Creating installer...')
function packageInstaller (filesPath, destArch, cb) {
console.log(`Windows: Creating ${destArch} installer...`)
const archStr = destArch === 'ia32' ? '-ia32' : ''
installer.createWindowsInstaller({
appDirectory: buildPath[0],
appDirectory: filesPath,
authors: config.APP_TEAM,
description: config.APP_NAME,
exe: config.APP_NAME + '.exe',
@@ -403,8 +416,26 @@ function buildWin32 (cb) {
noMsi: true,
outputDirectory: DIST_PATH,
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',
signWithParams: signWithParams,
title: config.APP_NAME,
@@ -412,23 +443,71 @@ function buildWin32 (cb) {
version: pkg.version
})
.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)
})
.catch(cb)
}
function packagePortable (cb) {
console.log('Windows: Creating portable app...')
function packagePortable (filesPath, destArch, cb) {
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)
var inPath = path.join(DIST_PATH, path.basename(buildPath[0]))
var outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip')
const downloadsPath = path.join(portablePath, 'Downloads')
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)
console.log('Windows: Created portable app.')
console.log(`Windows: Created ${destArch} portable app.`)
cb(null)
}
})
@@ -440,9 +519,9 @@ function buildLinux (cb) {
if (err) return cb(err)
console.log('Linux: Packaged electron. ' + buildPath)
var tasks = []
const tasks = []
buildPath.forEach(function (filesPath) {
var destArch = filesPath.split('-').pop()
const destArch = filesPath.split('-').pop()
if (argv.package === 'deb' || argv.package === 'all') {
tasks.push((cb) => packageDeb(filesPath, destArch, cb))
@@ -458,8 +537,8 @@ function buildLinux (cb) {
// Create .deb file for Debian-based platforms
console.log(`Linux: Creating ${destArch} deb...`)
var deb = require('nobin-debian-installer')()
var destPath = path.join('/opt', pkg.name)
const deb = require('nobin-debian-installer')()
const destPath = path.join('/opt', pkg.name)
deb.pack({
package: pkg,
@@ -477,6 +556,11 @@ function buildLinux (cb) {
dest: destPath,
expand: true,
cwd: filesPath
}, {
src: ['./**'],
dest: path.join('/usr', 'share'),
expand: true,
cwd: path.join(config.STATIC_PATH, 'linux', 'share')
}], function (err) {
if (err) return cb(err)
console.log(`Linux: Created ${destArch} deb.`)
@@ -488,8 +572,10 @@ function buildLinux (cb) {
// Create .zip file for Linux
console.log(`Linux: Creating ${destArch} zip...`)
var inPath = path.join(DIST_PATH, path.basename(filesPath))
var outPath = path.join(DIST_PATH, BUILD_NAME + '-linux-' + destArch + '.zip')
const archStr = destArch === 'ia32' ? '-ia32' : ''
const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, BUILD_NAME + '-linux' + archStr + '.zip')
zip.zipSync(inPath, outPath)
console.log(`Linux: Created ${destArch} zip.`)

View File

@@ -6,5 +6,4 @@ git diff --exit-code
npm run package -- --sign
git push
git push --tags
npm publish
./node_modules/.bin/gh-release
npm run gh-release

View File

@@ -2,16 +2,17 @@
# Update AUTHORS.md based on git history.
git log --reverse --format='%aN <%aE>' | perl -we '
git log --reverse --format='%aN (%aE)' | perl -we '
BEGIN {
%seen = (), @authors = ();
}
while (<>) {
next if $seen{$_};
next if /<support\@greenkeeper.io>/;
next if /<ungoldman\@gmail.com>/;
next if /<dc\@DCs-MacBook.local>/;
next if /<rolandoguedes\@gmail.com>/;
next if /(support\@greenkeeper.io)/;
next if /(ungoldman\@gmail.com)/;
next if /(dc\@DCs-MacBook.local)/;
next if /(rolandoguedes\@gmail.com)/;
next if /(grunjol\@users.noreply.github.com)/;
$seen{$_} = push @authors, "- ", $_;
}
END {

126
config.js
View File

@@ -1,126 +0,0 @@
var appConfig = require('application-config')('WebTorrent')
var fs = require('fs')
var path = require('path')
var APP_NAME = 'WebTorrent'
var APP_TEAM = 'WebTorrent, LLC'
var APP_VERSION = require('./package.json').version
var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings')
module.exports = {
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'),
APP_ICON: path.join(__dirname, 'static', 'WebTorrent'),
APP_NAME: APP_NAME,
APP_TEAM: APP_TEAM,
APP_VERSION: APP_VERSION,
APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
CONFIG_PATH: getConfigPath(),
DEFAULT_TORRENTS: [
{
name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent'
},
{
name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent'
},
{
name: 'Sintel',
posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent'
},
{
name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent'
},
{
name: 'The WIRED CD - Rip. Sample. Mash. Share.',
posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent'
}
],
DELAYED_INIT: 3000 /* 3 seconds */,
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
HOME_PAGE_URL: 'https://webtorrent.io',
IS_PORTABLE: isPortable(),
IS_PRODUCTION: isProduction(),
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
ROOT_PATH: __dirname,
STATIC_PATH: path.join(__dirname, 'static'),
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'),
WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html'),
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'),
WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents
WINDOW_MIN_WIDTH: 425
}
function getConfigPath () {
if (isPortable()) {
return PORTABLE_PATH
} else {
return path.dirname(appConfig.filePath)
}
}
function getDefaultDownloadPath () {
if (!process || !process.type) {
return ''
}
if (isPortable()) {
return path.join(getConfigPath(), 'Downloads')
}
var electron = require('electron')
return process.type === 'renderer'
? electron.remote.app.getPath('downloads')
: electron.app.getPath('downloads')
}
function isPortable () {
try {
return process.platform === 'win32' && isProduction() && !!fs.statSync(PORTABLE_PATH)
} catch (err) {
return false
}
}
function isProduction () {
if (!process.versions.electron) {
return false
}
if (process.platform === 'darwin') {
return !/\/Electron\.app\//.test(process.execPath)
}
if (process.platform === 'win32') {
return !/\\electron\.exe$/.test(process.execPath)
}
if (process.platform === 'linux') {
return !/\/electron$/.test(process.execPath)
}
}

View File

@@ -1 +1 @@
require('./main')
require('./build/main')

View File

@@ -1,163 +0,0 @@
console.time('init')
var electron = require('electron')
var app = electron.app
var ipcMain = electron.ipcMain
var announcement = require('./announcement')
var config = require('../config')
var crashReporter = require('../crash-reporter')
var dialog = require('./dialog')
var dock = require('./dock')
var handlers = require('./handlers')
var ipc = require('./ipc')
var log = require('./log')
var menu = require('./menu')
var squirrelWin32 = require('./squirrel-win32')
var tray = require('./tray')
var updater = require('./updater')
var windows = require('./windows')
var shouldQuit = false
var argv = sliceArgv(process.argv)
if (process.platform === 'win32') {
shouldQuit = squirrelWin32.handleEvent(argv[0])
argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1)
}
if (!shouldQuit) {
// Prevent multiple instances of app from running at same time. New instances signal
// this instance and quit.
shouldQuit = app.makeSingleInstance(onAppOpen)
if (shouldQuit) {
app.quit()
}
}
if (!shouldQuit) {
init()
}
function init () {
if (config.IS_PORTABLE) {
app.setPath('userData', config.CONFIG_PATH)
}
var isReady = false // app ready, windows can be created
app.ipcReady = false // main window has finished loading and IPC is ready
app.isQuitting = false
// Open handlers must be added as early as possible
app.on('open-file', onOpen)
app.on('open-url', onOpen)
ipc.init()
app.once('will-finish-launching', function () {
crashReporter.init()
})
app.on('ready', function () {
isReady = true
windows.main.init()
windows.webtorrent.init()
menu.init()
// To keep app startup fast, some code is delayed.
setTimeout(delayedInit, config.DELAYED_INIT)
// Report uncaught exceptions
process.on('uncaughtException', (err) => {
console.error(err)
var errJSON = {message: err.message, stack: err.stack}
windows.main.dispatch('uncaughtError', 'main', errJSON)
})
})
app.once('ipcReady', function () {
log('Command line args:', argv)
processArgv(argv)
console.timeEnd('init')
})
app.on('before-quit', function (e) {
if (app.isQuitting) return
app.isQuitting = true
e.preventDefault()
windows.main.dispatch('saveState') // try to save state on exit
ipcMain.once('savedState', () => app.quit())
setTimeout(() => {
console.error('Saving state took too long. Quitting.')
app.quit()
}, 2000) // quit after 2 secs, at most
})
app.on('activate', function () {
if (isReady) windows.main.show()
})
}
function delayedInit () {
announcement.init()
dock.init()
handlers.install()
tray.init()
updater.init()
}
function onOpen (e, torrentId) {
e.preventDefault()
if (app.ipcReady) {
// Magnet links opened from Chrome won't focus the app without a setTimeout.
// The confirmation dialog Chrome shows causes Chrome to steal back the focus.
// Electron issue: https://github.com/atom/electron/issues/4338
setTimeout(() => windows.main.show(), 100)
processArgv([ torrentId ])
} else {
argv.push(torrentId)
}
}
function onAppOpen (newArgv) {
newArgv = sliceArgv(newArgv)
if (app.ipcReady) {
log('Second app instance opened, but was prevented:', newArgv)
windows.main.show()
processArgv(newArgv)
} else {
argv.push(...newArgv)
}
}
function sliceArgv (argv) {
return argv.slice(config.IS_PRODUCTION ? 1 : 2)
}
function processArgv (argv) {
var torrentIds = []
argv.forEach(function (arg) {
if (arg === '-n') {
dialog.openSeedDirectory()
} else if (arg === '-o') {
dialog.openTorrentFile()
} else if (arg === '-u') {
dialog.openTorrentAddress()
} else if (arg.startsWith('-psn')) {
// Ignore OS X launchd "process serial number" argument
// Issue: https://github.com/feross/webtorrent-desktop/issues/214
} else {
torrentIds.push(arg)
}
})
if (torrentIds.length > 0) {
windows.main.dispatch('onOpen', torrentIds)
}
}

View File

@@ -1,182 +0,0 @@
module.exports = {
init
}
var electron = require('electron')
var app = electron.app
var dialog = require('./dialog')
var dock = require('./dock')
var log = require('./log')
var menu = require('./menu')
var powerSaveBlocker = require('./power-save-blocker')
var shell = require('./shell')
var shortcuts = require('./shortcuts')
var vlc = require('./vlc')
var windows = require('./windows')
var thumbar = require('./thumbar')
// Messages from the main process, to be sent once the WebTorrent process starts
var messageQueueMainToWebTorrent = []
// holds a ChildProcess while we're playing a video in VLC, null otherwise
var vlcProcess
function init () {
var ipc = electron.ipcMain
ipc.once('ipcReady', function (e) {
app.ipcReady = true
app.emit('ipcReady')
})
ipc.once('ipcReadyWebTorrent', function (e) {
app.ipcReadyWebTorrent = true
log('sending %d queued messages from the main win to the webtorrent window',
messageQueueMainToWebTorrent.length)
messageQueueMainToWebTorrent.forEach(function (message) {
windows.webtorrent.send(message.name, ...message.args)
log('webtorrent: sent queued %s', message.name)
})
})
/**
* Dialog
*/
ipc.on('openTorrentFile', () => dialog.openTorrentFile())
ipc.on('openFiles', () => dialog.openFiles())
/**
* Dock
*/
ipc.on('setBadge', (e, ...args) => dock.setBadge(...args))
ipc.on('downloadFinished', (e, ...args) => dock.downloadFinished(...args))
/**
* Events
*/
ipc.on('onPlayerOpen', function () {
menu.onPlayerOpen()
powerSaveBlocker.enable()
shortcuts.enable()
thumbar.enable()
})
ipc.on('onPlayerClose', function () {
menu.onPlayerClose()
powerSaveBlocker.disable()
shortcuts.disable()
thumbar.disable()
})
ipc.on('onPlayerPlay', function () {
powerSaveBlocker.enable()
thumbar.onPlayerPlay()
})
ipc.on('onPlayerPause', function () {
powerSaveBlocker.disable()
thumbar.onPlayerPause()
})
/**
* Shell
*/
ipc.on('openItem', (e, ...args) => shell.openItem(...args))
ipc.on('showItemInFolder', (e, ...args) => shell.showItemInFolder(...args))
ipc.on('moveItemToTrash', (e, ...args) => shell.moveItemToTrash(...args))
/**
* Windows: Main
*/
var main = windows.main
ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
ipc.on('setProgress', (e, ...args) => main.setProgress(...args))
ipc.on('setTitle', (e, ...args) => main.setTitle(...args))
ipc.on('show', () => main.show())
ipc.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args))
/**
* VLC
* TODO: Move most of this code to vlc.js
*/
ipc.on('checkForVLC', function (e) {
vlc.checkForVLC(function (isInstalled) {
windows.main.send('checkForVLC', isInstalled)
})
})
ipc.on('vlcPlay', function (e, url) {
var args = ['--play-and-exit', '--video-on-top', '--no-video-title-show', '--quiet', url]
log('Running vlc ' + args.join(' '))
vlc.spawn(args, function (err, proc) {
if (err) return windows.main.dispatch('vlcNotFound')
vlcProcess = proc
// If it works, close the modal after a second
var closeModalTimeout = setTimeout(() =>
windows.main.dispatch('exitModal'), 1000)
vlcProcess.on('close', function (code) {
clearTimeout(closeModalTimeout)
if (!vlcProcess) return // Killed
log('VLC exited with code ', code)
if (code === 0) {
windows.main.dispatch('backToList')
} else {
windows.main.dispatch('vlcNotFound')
}
vlcProcess = null
})
vlcProcess.on('error', function (e) {
log('VLC error', e)
})
})
})
ipc.on('vlcQuit', function () {
if (!vlcProcess) return
log('Killing VLC, pid ' + vlcProcess.pid)
vlcProcess.kill('SIGKILL') // kill -9
vlcProcess = null
})
// Capture all events
var oldEmit = ipc.emit
ipc.emit = function (name, e, ...args) {
// Relay messages between the main window and the WebTorrent hidden window
if (name.startsWith('wt-') && !app.isQuitting) {
if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
// Send message to main window
windows.main.send(name, ...args)
log('webtorrent: got %s', name)
} else if (app.ipcReadyWebTorrent) {
// Send message to webtorrent window
windows.webtorrent.send(name, ...args)
log('webtorrent: sent %s', name)
} else {
// Queue message for webtorrent window, it hasn't finished loading yet
messageQueueMainToWebTorrent.push({
name: name,
args: args
})
log('webtorrent: queueing %s', name)
}
return
}
// Emit all other events normally
oldEmit.call(ipc, name, e, ...args)
}
}

View File

@@ -1,20 +0,0 @@
module.exports = {
disable,
enable
}
var electron = require('electron')
var windows = require('./windows')
function enable () {
// Register play/pause media key, available on some keyboards.
electron.globalShortcut.register(
'MediaPlayPause',
() => windows.main.dispatch('playPause')
)
}
function disable () {
// Return the media key to the OS, so other apps can use it.
electron.globalShortcut.unregister('MediaPlayPause')
}

View File

@@ -1,54 +0,0 @@
module.exports = {
disable,
enable,
onPlayerPause,
onPlayerPlay
}
/**
* On Windows, add a "thumbnail toolbar" with a play/pause button in the taskbar.
* This provides users a way to access play/pause functionality without restoring
* or activating the window.
*/
var path = require('path')
var config = require('../config')
var windows = require('./windows')
/**
* Show the Windows thumbnail toolbar buttons.
*/
function enable () {
update(false)
}
/**
* Hide the Windows thumbnail toolbar buttons.
*/
function disable () {
windows.main.win.setThumbarButtons([])
}
function onPlayerPause () {
update(true)
}
function onPlayerPlay () {
update(false)
}
function update (isPaused) {
var icon = isPaused
? 'PlayThumbnailBarButton.png'
: 'PauseThumbnailBarButton.png'
var buttons = [
{
tooltip: isPaused ? 'Play' : 'Pause',
icon: path.join(config.STATIC_PATH, icon),
click: () => windows.main.dispatch('playPause')
}
]
windows.main.win.setThumbarButtons(buttons)
}

View File

@@ -1,22 +0,0 @@
module.exports = {
checkForVLC,
spawn
}
var cp = require('child_process')
var vlcCommand = require('vlc-command')
// Finds if VLC is installed on Mac, Windows, or Linux.
// Calls back with true or false: whether VLC was detected
function checkForVLC (cb) {
vlcCommand((err) => cb(!err))
}
// Spawns VLC with child_process.spawn() to return a ChildProcess object
// Calls back with (err, childProcess)
function spawn (args, cb) {
vlcCommand(function (err, vlcPath) {
if (err) return cb(err)
cb(null, cp.spawn(vlcPath, args))
})
}

View File

@@ -1,62 +1,78 @@
{
"name": "webtorrent-desktop",
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
"version": "0.9.0",
"description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.",
"version": "0.17.1",
"author": {
"name": "WebTorrent, LLC",
"email": "feross@webtorrent.io",
"url": "https://webtorrent.io"
},
"bin": {
"webtorrent-desktop": "./bin/cmd.js"
},
"bugs": {
"url": "https://github.com/feross/webtorrent-desktop/issues"
},
"dependencies": {
"airplayer": "^2.0.0",
"application-config": "^0.2.1",
"application-config": "^1.0.0",
"arch": "^2.0.0",
"auto-launch": "^4.0.1",
"bitfield": "^1.0.2",
"capture-frame": "^1.0.0",
"chromecasts": "^1.8.0",
"cp-file": "^4.0.1",
"create-torrent": "^3.24.5",
"debounce": "^1.0.0",
"deep-equal": "^1.0.1",
"dlnacasts": "^0.1.0",
"drag-drop": "^2.11.0",
"electron-prebuilt": "1.2.1",
"fs-extra": "^0.27.0",
"hyperx": "^2.0.2",
"drag-drop": "^2.12.1",
"es6-error": "^3.0.1",
"fn-getter": "^1.0.0",
"iso-639-1": "^1.2.1",
"languagedetect": "^1.1.1",
"main-loop": "^3.2.0",
"location-history": "^1.0.0",
"lodash.merge": "^4.6.0",
"material-ui": "^0.15.4",
"mkdirp": "^0.5.1",
"musicmetadata": "^2.0.2",
"network-address": "^1.1.0",
"parse-torrent": "^5.7.3",
"prettier-bytes": "^1.0.1",
"react": "^15.2.1",
"react-dom": "^15.2.1",
"react-tap-event-plugin": "^1.0.0",
"rimraf": "^2.5.2",
"run-parallel": "^1.1.6",
"semver": "^5.1.0",
"simple-concat": "^1.0.0",
"simple-get": "^2.0.0",
"srt-to-vtt": "^1.1.1",
"virtual-dom": "^2.1.1",
"vlc-command": "^1.0.1",
"webtorrent": "0.x",
"winreg": "^1.2.0",
"zero-fill": "^2.2.3"
},
"devDependencies": {
"buble": "^0.14.0",
"cross-zip": "^2.0.1",
"depcheck": "^0.6.4",
"electron": "1.4.2",
"electron-osx-sign": "^0.3.0",
"electron-packager": "^7.0.0",
"electron-packager": "^8.0.0",
"electron-winstaller": "^2.3.0",
"gh-release": "^2.0.3",
"minimist": "^1.2.0",
"mkdirp": "^0.5.1",
"nobin-debian-installer": "^0.0.10",
"nodemon": "^1.10.2",
"open": "0.0.5",
"plist": "^1.2.0",
"rimraf": "^2.5.2",
"plist": "^2.0.1",
"pngjs": "^3.0.0",
"run-series": "^1.1.4",
"standard": "^7.0.0"
"spectron": "^3.3.0",
"standard": "*",
"tape": "^4.6.0",
"walk-sync": "^0.3.1"
},
"engines": {
"node": ">=4.0.0"
},
"homepage": "https://webtorrent.io",
"keywords": [
@@ -65,8 +81,8 @@
"electron-app",
"hybrid webtorrent client",
"mad science",
"torrent client",
"torrent",
"torrent client",
"webtorrent"
],
"license": "MIT",
@@ -80,11 +96,16 @@
"url": "git://github.com/feross/webtorrent-desktop.git"
},
"scripts": {
"build": "buble src --output build",
"clean": "node ./bin/clean.js",
"gh-release": "gh-release",
"open-config": "node ./bin/open-config.js",
"package": "node ./bin/package.js",
"start": "electron .",
"test": "standard && node ./bin/check-deps.js",
"update-authors": "./bin/update-authors.sh"
"prepublish": "npm run build",
"start": "npm run build && electron .",
"test": "standard && depcheck --ignores=buble,lodash.merge,nodemon,gh-release --ignore-dirs=build,dist && node ./bin/extra-lint.js",
"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,56 +0,0 @@
const electron = require('electron')
const ipcRenderer = electron.ipcRenderer
// Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc)
module.exports = class MediaController {
constructor (state) {
this.state = state
}
mediaSuccess () {
this.state.playing.result = 'success'
}
mediaStalled () {
this.state.playing.isStalled = true
}
mediaError (error) {
var state = this.state
if (state.location.url() === 'player') {
state.playing.result = 'error'
state.playing.location = 'error'
ipcRenderer.send('checkForVLC')
ipcRenderer.once('checkForVLC', function (e, isInstalled) {
state.modal = {
id: 'unsupported-media-modal',
error: error,
vlcInstalled: isInstalled
}
})
}
}
mediaTimeUpdate () {
this.state.playing.lastTimeUpdate = new Date().getTime()
this.state.playing.isStalled = false
}
mediaMouseMoved () {
this.state.playing.mouseStationarySince = new Date().getTime()
}
vlcPlay () {
ipcRenderer.send('vlcPlay', this.state.server.localURL)
this.state.playing.location = 'vlc'
}
vlcNotFound () {
var modal = this.state.modal
if (modal && modal.id === 'unsupported-media-modal') {
modal.vlcNotFound = true
}
}
}

View File

@@ -1,309 +0,0 @@
const electron = require('electron')
const path = require('path')
const Cast = require('../lib/cast')
const {dispatch} = require('../lib/dispatcher')
const telemetry = require('../lib/telemetry')
const errors = require('../lib/errors')
const sound = require('../lib/sound')
const TorrentPlayer = require('../lib/torrent-player')
const TorrentSummary = require('../lib/torrent-summary')
const State = require('../lib/state')
const ipcRenderer = electron.ipcRenderer
// Controls playback of torrents and files within torrents
// both local (<video>,<audio>,VLC) and remote (cast)
module.exports = class PlaybackController {
constructor (state, config, update) {
this.state = state
this.config = config
this.update = update
}
// Play a file in a torrent.
// * Start torrenting, if necessary
// * Stream, if not already fully downloaded
// * If no file index is provided, pick the default file to play
playFile (infoHash, index /* optional */) {
this.state.location.go({
url: 'player',
onbeforeload: (cb) => {
this.play()
openPlayer(this.state, infoHash, index, cb)
},
onbeforeunload: (cb) => closePlayer(this.state, this.config, cb)
}, (err) => {
if (err) dispatch('error', err)
})
}
// Show a file in the OS, eg in Finder on a Mac
openItem (infoHash, index) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
var filePath = path.join(
torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
}
// Toggle (play or pause) the currently playing media
playPause () {
var state = this.state
if (state.location.url() !== 'player') return
// force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately
var mediaTag = document.querySelector('video,audio')
if (!state.window.isVisible && mediaTag) {
if (state.playing.isPaused) mediaTag.play()
else mediaTag.pause()
}
if (state.playing.isPaused) this.play()
else this.pause()
}
// Play (unpause) the current media
play () {
var state = this.state
if (!state.playing.isPaused) return
state.playing.isPaused = false
if (isCasting(state)) {
Cast.play()
}
ipcRenderer.send('onPlayerPlay')
}
// Pause the currently playing media
pause () {
var state = this.state
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting(state)) {
Cast.pause()
}
ipcRenderer.send('onPlayerPause')
}
// Skip specified number of seconds (backwards if negative)
skip (time) {
this.skipTo(this.state.playing.currentTime + time)
}
// Skip (aka seek) to a specific point, in seconds
skipTo (time) {
if (isCasting(this.state)) Cast.seek(time)
else this.state.playing.jumpToTime = time
}
// Change playback speed. 1 = faster, -1 = slower
// Playback speed ranges from 16 (fast forward) to 1 (normal playback)
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
// until -16 (fast rewind)
changePlaybackRate (direction) {
var state = this.state
var rate = state.playing.playbackRate
if (direction > 0 && rate >= 0.25 && rate < 2) {
rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25
} else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */
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
} else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) {
rate /= 2
}
state.playing.playbackRate = rate
if (isCasting(state) && !Cast.setRate(rate)) {
state.playing.playbackRate = 1
}
}
// Change the volume, in range [0, 1], by some amount
// For example, volume muted (0), changeVolume (0.3) increases to 30% volume
changeVolume (delta) {
// change volume with delta value
this.setVolume(this.state.playing.volume + delta)
}
// Set the volume to some value in [0, 1]
setVolume (volume) {
// check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume))
var state = this.state
if (isCasting(state)) {
Cast.setVolume(volume)
} else {
state.playing.setVolume = volume
}
}
// 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
showOrHidePlayerControls () {
var state = this.state
var hideControls = state.location.url() === 'player' &&
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local'
if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls
return true
}
return false
}
}
// Opens the video player to a specific torrent
function openPlayer (state, infoHash, index, cb) {
var torrentSummary = TorrentSummary.getByKey(state, infoHash)
// automatically choose which file in the torrent to play, if necessary
if (index === undefined) index = torrentSummary.defaultPlayFileIndex
if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files)
if (index === undefined) return cb(new errors.UnplayableError())
// update UI to show pending playback
if (torrentSummary.progress !== 1) sound.play('PLAY')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'requested'
this.update()
var timeout = setTimeout(() => {
telemetry.logPlayAttempt('timeout')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR')
cb(new Error('Playback timed out. Try again.'))
this.update()
}, 10000) /* give it a few seconds */
if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb))
} else {
openPlayerFromActiveTorrent(state, torrentSummary, index, timeout, cb)
}
}
function openPlayerFromActiveTorrent (state, torrentSummary, index, timeout, cb) {
var fileSummary = torrentSummary.files[index]
// update state
state.playing.infoHash = torrentSummary.infoHash
state.playing.fileIndex = index
state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video'
: TorrentPlayer.isAudio(fileSummary) ? 'audio'
: 'other'
// pick up where we left off
if (fileSummary.currentTime) {
var fraction = fileSummary.currentTime / fileSummary.duration
var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime
}
}
// if it's audio, parse out the metadata (artist, title, etc)
if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
}
// if it's video, check for subtitles files that are done downloading
dispatch('checkForSubtitles')
// enable previously selected subtitle track
if (fileSummary.selectedSubtitle) {
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
}
ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index)
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => {
clearTimeout(timeout)
// if we timed out (user clicked play a long time ago), don't autoplay
var timedOut = torrentSummary.playStatus === 'timeout'
delete torrentSummary.playStatus
if (timedOut) {
ipcRenderer.send('wt-stop-server')
return this.update()
}
// otherwise, play the video
state.window.title = torrentSummary.files[state.playing.fileIndex].name
this.update()
ipcRenderer.send('onPlayerOpen')
cb()
})
}
function closePlayer (state, config, cb) {
console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC
if (isCasting(state)) {
Cast.stop()
}
if (state.playing.location === 'vlc') {
ipcRenderer.send('vlcQuit')
}
// Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume
// Telemetry: track what happens after the user clicks play
var result = state.playing.result // 'success' or 'error'
if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed
else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc
else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen
state.window.title = config.APP_WINDOW_TITLE
state.playing = State.getDefaultPlayState()
state.server = null
// Reset the window size and location back to where it was
if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false)
}
restoreBounds(state)
// Tell the WebTorrent process to kill the torrent-to-HTTP server
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('onPlayerClose')
this.update()
cb()
}
// Checks whether we are connected and already casting
// Returns false if we not casting (state.playing.location === 'local')
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
function isCasting (state) {
return state.playing.location === 'chromecast' ||
state.playing.location === 'airplay' ||
state.playing.location === 'dlna'
}
function restoreBounds (state) {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}

View File

@@ -1,51 +0,0 @@
const State = require('../lib/state')
// Controls the Preferences screen
module.exports = class PrefsController {
constructor (state, config) {
this.state = state
this.config = config
}
// Goes to the Preferences screen
show () {
var state = this.state
state.location.go({
url: 'preferences',
onbeforeload: function (cb) {
// initialize preferences
state.window.title = 'Preferences'
state.unsaved = Object.assign(state.unsaved || {}, {prefs: state.saved.prefs || {}})
cb()
},
onbeforeunload: (cb) => {
// save state after preferences
this.save()
state.window.title = this.config.APP_WINDOW_TITLE
cb()
}
})
}
// Updates a single property in the UNSAVED prefs
// For example: updatePreferences("foo.bar", "baz")
// Call savePreferences to save to config.json
update (property, value) {
var path = property.split('.')
var key = this.state.unsaved.prefs
for (var i = 0; i < path.length - 1; i++) {
if (typeof key[path[i]] === 'undefined') {
key[path[i]] = {}
}
key = key[path[i]]
}
key[path[i]] = value
}
// All unsaved prefs take effect atomically, and are saved to config.json
save () {
var state = this.state
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
State.save(state)
}
}

View File

@@ -1,282 +0,0 @@
const fs = require('fs')
const path = require('path')
const electron = require('electron')
const {dispatch} = require('../lib/dispatcher')
const State = require('../lib/state')
const sound = require('../lib/sound')
const TorrentSummary = require('../lib/torrent-summary')
const ipcRenderer = electron.ipcRenderer
const instantIoRegex = /^(https:\/\/)?instant\.io\/#/
// Controls the torrent list: creating, adding, deleting, & manipulating torrents
module.exports = class TorrentListController {
constructor (state) {
this.state = state
}
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
addTorrent (torrentId) {
if (torrentId.path) {
// Use path string instead of W3C File object
torrentId = torrentId.path
}
// Allow a instant.io link to be pasted
// TODO: remove this once support is added to webtorrent core
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
}
var torrentKey = this.state.nextTorrentKey++
var path = this.state.saved.prefs.downloadPath
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
dispatch('backToList')
}
// Shows the Create Torrent page with options to seed a given file or folder
showCreateTorrent (files) {
// Files will either be an array of file objects, which we can send directly
// to the create-torrent screen
if (files.length === 0 || typeof files[0] !== 'string') {
this.state.location.go({
url: 'create-torrent',
files: files
})
return
}
// ... or it will be an array of mixed file and folder paths. We have to walk
// through all the folders and find the files
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
createTorrent (options) {
var state = this.state
var torrentKey = state.nextTorrentKey++
ipcRenderer.send('wt-create-torrent', torrentKey, options)
state.location.backToFirst(function () {
state.location.clearForward('create-torrent')
})
}
// Starts downloading and/or seeding a given torrentSummary.
startTorrentingSummary (torrentSummary) {
var s = torrentSummary
// Backward compatibility for config files save before we had torrentKey
if (!s.torrentKey) s.torrentKey = this.state.nextTorrentKey++
// Use Downloads folder by default
if (!s.path) s.path = this.state.saved.prefs.downloadPath
ipcRenderer.send('wt-start-torrenting',
s.torrentKey,
TorrentSummary.getTorrentID(s),
s.path,
s.fileModtimes,
s.selections)
}
// TODO: use torrentKey, not infoHash
toggleTorrent (infoHash) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new'
this.startTorrentingSummary(torrentSummary)
sound.play('ENABLE')
} else {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
}
}
toggleTorrentFile (infoHash, index) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
torrentSummary.selections[index] = !torrentSummary.selections[index]
// Let the WebTorrent process know to start or stop fetching that file
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
}
confirmDeleteTorrent (infoHash, deleteData) {
this.state.modal = {
id: 'remove-torrent-modal',
infoHash,
deleteData
}
}
// TODO: use torrentKey, not infoHash
deleteTorrent (infoHash, deleteData) {
ipcRenderer.send('wt-stop-torrenting', infoHash)
var index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) {
var summary = this.state.saved.torrents[index]
// remove torrent and poster file
deleteFile(TorrentSummary.getTorrentPath(summary))
deleteFile(TorrentSummary.getPosterPath(summary)) // TODO: will the css path hack affect windows?
// optionally delete the torrent data
if (deleteData) moveItemToTrash(summary)
// remove torrent from saved list
this.state.saved.torrents.splice(index, 1)
State.saveThrottled(this.state)
}
this.state.location.clearForward('player') // prevent user from going forward to a deleted torrent
sound.play('DELETE')
}
toggleSelectTorrent (infoHash) {
if (this.state.selectedInfoHash === infoHash) {
this.state.selectedInfoHash = null
} else {
this.state.selectedInfoHash = infoHash
}
}
openTorrentContextMenu (infoHash) {
var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
var menu = new electron.remote.Menu()
menu.append(new electron.remote.MenuItem({
label: 'Remove From List',
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, false)
}))
menu.append(new electron.remote.MenuItem({
label: 'Remove Data File',
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, true)
}))
menu.append(new electron.remote.MenuItem({
type: 'separator'
}))
if (torrentSummary.files) {
menu.append(new electron.remote.MenuItem({
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder',
click: () => showItemInFolder(torrentSummary)
}))
menu.append(new electron.remote.MenuItem({
type: 'separator'
}))
}
menu.append(new electron.remote.MenuItem({
label: 'Copy Magnet Link to Clipboard',
click: () => electron.clipboard.writeText(torrentSummary.magnetURI)
}))
menu.append(new electron.remote.MenuItem({
label: 'Copy Instant.io Link to Clipboard',
click: () => electron.clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`)
}))
menu.append(new electron.remote.MenuItem({
label: 'Save Torrent File As...',
click: () => saveTorrentFileAs(torrentSummary)
}))
menu.popup(electron.remote.getCurrentWindow())
}
}
// Recursively finds {name, path, size} for all files in a folder
// Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (paths, cb) {
if (paths.length > 1) {
var numComplete = 0
var ret = []
paths.forEach(function (path) {
findFilesRecursive([path], function (fileObjs) {
ret = ret.concat(fileObjs)
if (++numComplete === paths.length) {
ret.sort((a, b) => a.path < b.path ? -1 : a.path > b.path)
cb(ret)
}
})
})
return
}
var fileOrFolder = paths[0]
fs.stat(fileOrFolder, function (err, stat) {
if (err) return dispatch('error', err)
// Files: return name, path, and size
if (!stat.isDirectory()) {
var filePath = fileOrFolder
return cb([{
name: path.basename(filePath),
path: filePath,
size: stat.size
}])
}
// Folders: recurse, make a list of all the files
var folderPath = fileOrFolder
fs.readdir(folderPath, function (err, fileNames) {
if (err) return dispatch('error', err)
var paths = fileNames.map((fileName) => path.join(folderPath, fileName))
findFilesRecursive(paths, cb)
})
})
}
function deleteFile (path) {
if (!path) return
fs.unlink(path, function (err) {
if (err) dispatch('error', err)
})
}
// Delete all files in a torrent
function moveItemToTrash (torrentSummary) {
var filePath = TorrentSummary.getFileOrFolder(torrentSummary)
ipcRenderer.send('moveItemToTrash', filePath)
}
function showItemInFolder (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) {
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,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

@@ -1,8 +0,0 @@
module.exports = {
UnplayableError
}
function UnplayableError () {
this.message = 'Can\'t play any files in torrent'
}
UnplayableError.prototype = Error

View File

@@ -1,5 +0,0 @@
var h = require('virtual-dom/h')
var hyperx = require('hyperx')
var hx = hyperx(h)
module.exports = hx

View File

@@ -1,126 +0,0 @@
module.exports = LocationHistory
function LocationHistory () {
if (!new.target) return new LocationHistory()
this._history = []
this._forward = []
this._pending = false
}
LocationHistory.prototype.url = function () {
return this.current() && this.current().url
}
LocationHistory.prototype.current = function () {
return this._history[this._history.length - 1]
}
LocationHistory.prototype.go = function (page, cb) {
if (!cb) cb = noop
if (this._pending) return cb(null)
console.log('go', page)
this.clearForward()
this._go(page, cb)
}
LocationHistory.prototype.back = function (cb) {
var self = this
if (!cb) cb = noop
if (self._history.length <= 1 || self._pending) return cb(null)
var page = self._history.pop()
self._unload(page, done)
function done (err) {
if (err) return cb(err)
self._forward.push(page)
self._load(self.current(), cb)
}
}
LocationHistory.prototype.hasBack = function () {
return this._history.length > 1
}
LocationHistory.prototype.forward = function (cb) {
if (!cb) cb = noop
if (this._forward.length === 0 || this._pending) return cb(null)
var page = this._forward.pop()
this._go(page, cb)
}
LocationHistory.prototype.hasForward = function () {
return this._forward.length > 0
}
LocationHistory.prototype.clearForward = function (url) {
if (url == null) {
this._forward = []
} else {
console.log(this._forward)
console.log(url)
this._forward = this._forward.filter(function (page) {
return page.url !== url
})
}
}
LocationHistory.prototype.backToFirst = function (cb) {
var self = this
if (!cb) cb = noop
if (self._history.length <= 1) return cb(null)
self.back(function (err) {
if (err) return cb(err)
self.backToFirst(cb)
})
}
LocationHistory.prototype._go = function (page, cb) {
var self = this
if (!cb) cb = noop
self._unload(self.current(), done1)
function done1 (err) {
if (err) return cb(err)
self._load(page, done2)
}
function done2 (err) {
if (err) return cb(err)
self._history.push(page)
cb(null)
}
}
LocationHistory.prototype._load = function (page, cb) {
var self = this
self._pending = true
if (page && page.onbeforeload) page.onbeforeload(done)
else done(null)
function done (err) {
self._pending = false
cb(err)
}
}
LocationHistory.prototype._unload = function (page, cb) {
var self = this
self._pending = true
if (page && page.onbeforeunload) page.onbeforeunload(done)
else done(null)
function done (err) {
self._pending = false
cb(err)
}
}
function noop () {}

View File

@@ -1,95 +0,0 @@
/* eslint-disable camelcase */
module.exports = {
run
}
var semver = require('semver')
var config = require('../../config')
// Change `state.saved` (which will be saved back to config.json on exit) as
// needed, for example to deal with config.json format changes across versions
function run (state) {
// Replace "{ version: 1 }" with app version (semver)
if (!semver.valid(state.saved.version)) {
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
}
var version = state.saved.version
if (semver.lt(version, '0.7.0')) {
migrate_0_7_0(state.saved)
}
if (semver.lt(version, '0.7.2')) {
migrate_0_7_2(state.saved)
}
// Config is now on the new version
state.saved.version = config.APP_VERSION
}
function migrate_0_7_0 (saved) {
console.log('migrate to 0.7.0')
var fs = require('fs-extra')
var path = require('path')
saved.torrents.forEach(function (ts) {
var infoHash = ts.infoHash
// Replace torrentPath with torrentFileName
// There are a number of cases to handle here:
// * Originally we used absolute paths
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
// * Finally, now we're getting rid of torrentPath altogether
var src, dst
if (ts.torrentPath) {
console.log('replacing torrentPath %s', ts.torrentPath)
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
src = ts.torrentPath
} else {
src = path.join(config.STATIC_PATH, ts.torrentPath)
}
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent'
}
// Replace posterURL with posterFileName
if (ts.posterURL) {
console.log('replacing posterURL %s', ts.posterURL)
var extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL)
? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) fs.copySync(src, dst)
delete ts.posterURL
ts.posterFileName = infoHash + extension
}
// Fix exception caused by incorrect file ordering.
// https://github.com/feross/webtorrent-desktop/pull/604#issuecomment-222805214
delete ts.defaultPlayFileIndex
delete ts.files
delete ts.selections
delete ts.fileModtimes
})
}
function migrate_0_7_2 (saved) {
if (!saved.prefs) {
saved.prefs = {
downloadPath: config.DEFAULT_DOWNLOAD_PATH
}
}
}

View File

@@ -1,152 +0,0 @@
// Collects anonymous usage stats and uncaught errors
// Reports back so that we can improve WebTorrent Desktop
module.exports = {
init,
logUncaughtError,
logPlayAttempt
}
const crypto = require('crypto')
const electron = require('electron')
const https = require('https')
const os = require('os')
const url = require('url')
const config = require('../../config')
var telemetry
function init (state) {
telemetry = state.saved.telemetry
if (!telemetry) {
telemetry = state.saved.telemetry = createSummary()
reset()
}
var now = new Date()
telemetry.timestamp = now.toISOString()
telemetry.localTime = now.toTimeString()
telemetry.screens = getScreenInfo()
telemetry.system = getSystemInfo()
telemetry.approxNumTorrents = getApproxNumTorrents(state)
if (config.IS_PRODUCTION) {
postToServer()
} else {
// Development: telemetry used only for local debugging
// Empty uncaught errors, etc at the start of every run
reset()
}
}
function reset () {
telemetry.uncaughtErrors = []
telemetry.playAttempts = {
total: 0,
success: 0,
timeout: 0,
error: 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
function getScreenInfo () {
return electron.screen.getAllDisplays().map((screen) => ({
width: screen.size.width,
height: screen.size.height,
scaleFactor: screen.scaleFactor
}))
}
// Track basic system info like OS version and amount of RAM
function getSystemInfo () {
return {
osPlatform: process.platform,
osRelease: os.type() + ' ' + os.release(),
architecture: os.arch(),
totalMemoryMB: os.totalmem() / (1 << 20),
numCores: os.cpus().length
}
}
// Get the number of torrents, rounded to the nearest power of two
function getApproxNumTorrents (state) {
var exactNum = state.saved.torrents.length
if (exactNum === 0) return 0
// Otherwise, return 1, 2, 4, 8, etc by rounding in log space
var log2 = Math.log(exactNum) / Math.log(2)
return 1 << Math.round(log2)
}
// An uncaught error happened in the main process or in one of the windows
function logUncaughtError (procName, err) {
console.error('uncaught error', procName, err)
// Not initialized yet? Ignore.
// Hopefully uncaught errors immediately on startup are fixed in dev
if (!telemetry) return
var message, stack
if (err instanceof Error) {
message = err.message
stack = err.stack
} else {
message = String(err)
stack = ''
}
// We need to POST the telemetry object, make sure it stays < 100kb
if (telemetry.uncaughtErrors.length > 20) return
if (message.length > 1000) message = message.substring(0, 1000)
if (stack.length > 1000) stack = stack.substring(0, 1000)
telemetry.uncaughtErrors.push({process: procName, message, stack})
}
// The user pressed play. It either worked, timed out, or showed the
// "Play in VLC" codec error
function logPlayAttempt (result) {
if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) {
return console.error('Unknown play attempt result', result)
}
var attempts = telemetry.playAttempts
attempts.total = (attempts.total || 0) + 1
attempts[result] = (attempts[result] || 0) + 1
}

View File

@@ -1,447 +0,0 @@
console.time('init')
const crashReporter = require('../crash-reporter')
crashReporter.init()
const dragDrop = require('drag-drop')
const electron = require('electron')
const mainLoop = require('main-loop')
const createElement = require('virtual-dom/create-element')
const diff = require('virtual-dom/diff')
const patch = require('virtual-dom/patch')
const config = require('../config')
const App = require('./views/app')
const telemetry = require('./lib/telemetry')
const sound = require('./lib/sound')
const State = require('./lib/state')
const TorrentPlayer = require('./lib/torrent-player')
const MediaController = require('./controllers/media-controller')
const UpdateController = require('./controllers/update-controller')
const PrefsController = require('./controllers/prefs-controller')
const PlaybackController = require('./controllers/playback-controller')
const SubtitlesController = require('./controllers/subtitles-controller')
const TorrentListController = require('./controllers/torrent-list-controller')
const TorrentController = require('./controllers/torrent-controller')
// Yo-yo pattern: state object lives here and percolates down thru all the views.
// Events come back up from the views via dispatch(...)
require('./lib/dispatcher').setDispatch(dispatch)
// From dispatch(...), events are sent to one of the controllers
var controllers = null
// This dependency is the slowest-loading, so we lazy load it
var 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 other state is ephemeral. First we load state.saved then initialize the app.
var state, vdomLoop
State.load(onState)
// Called once when the application loads. (Not once per window.)
// Connects to the torrent networks, sets up the UI and OS integrations like
// the dock icon and drag+drop.
function onState (err, _state) {
if (err) return onError(err)
state = _state
// Create controllers
controllers = {
media: new MediaController(state),
update: new UpdateController(state),
prefs: new PrefsController(state, config),
playback: new PlaybackController(state, config, update),
subtitles: new SubtitlesController(state),
torrentList: new TorrentListController(state),
torrent: new TorrentController(state)
}
// Add first page to location history
state.location.go({ url: 'home' })
// Restart everything we were torrenting last time the app ran
resumeTorrents()
// Lazy-load other stuff, like the AppleTV module, later to keep startup fast
window.setTimeout(delayedInit, config.DELAYED_INIT)
// The UI is built with virtual-dom, a minimalist library extracted from React
// The concepts--one way data flow, a pure function that renders state to a
// virtual DOM tree, and a diff that applies changes in the vdom to the real
// DOM, are all the same. Learn more: https://facebook.github.io/react/
vdomLoop = mainLoop(state, render, {
create: createElement,
diff: diff,
patch: patch
})
document.body.appendChild(vdomLoop.target)
// Listen for messages from the main process
setupIpc()
// Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000)
// OS integrations:
// ...drag and drop a torrent or video file to play or seed
dragDrop('body', onOpen)
// ...same thing if you paste a torrent
document.addEventListener('paste', onPaste)
// ...focus and blur. Needed to show correct dock icon text ("badge") in OSX
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
// ...window visibility state.
document.addEventListener('webkitvisibilitychange', onVisibilityChange)
// Log uncaught JS errors
window.addEventListener('error',
(e) => telemetry.logUncaughtError('window', e.error || e.target), true)
// Done! Ideally we want to get here < 500ms after the user clicks the app
sound.play('STARTUP')
console.timeEnd('init')
}
// Runs a few seconds after the app loads, to avoid slowing down startup time
function delayedInit () {
lazyLoadCast()
sound.preload()
telemetry.init(state)
}
// Lazily loads Chromecast and Airplay support
function lazyLoadCast () {
if (!Cast) {
Cast = require('./lib/cast')
Cast.init(state, update) // Search the local network for Chromecast and Airplays
}
return Cast
}
// This is the (mostly) pure function from state -> UI. Returns a virtual DOM
// tree. Any events, such as button clicks, will turn into calls to dispatch()
function render (state) {
try {
return App(state)
} catch (e) {
console.log('rendering error: %s\n\t%s', e.message, e.stack)
}
}
// Calls render() to go from state -> UI, then applies to vdom to the real DOM.
function update () {
controllers.playback.showOrHidePlayerControls()
vdomLoop.update(state)
updateElectron()
}
// Some state changes can't be reflected in the DOM, instead we have to
// tell the main process to update the window or OS integrations
function updateElectron () {
if (state.window.title !== state.prev.title) {
state.prev.title = state.window.title
ipcRenderer.send('setTitle', state.window.title)
}
if (state.dock.progress !== state.prev.progress) {
state.prev.progress = state.dock.progress
ipcRenderer.send('setProgress', state.dock.progress)
}
if (state.dock.badge !== state.prev.badge) {
state.prev.badge = state.dock.badge
ipcRenderer.send('setBadge', state.dock.badge || '')
}
}
const dispatchHandlers = {
// Torrent list: creating, deleting, selecting torrents
'openTorrentFile': () => ipcRenderer.send('openTorrentFile'),
'openFiles': () => ipcRenderer.send('openFiles'), /* shows the open file dialog */
'openTorrentAddress': () => { state.modal = { id: 'open-torrent-address-modal' } },
'addTorrent': (torrentId) => controllers.torrentList.addTorrent(torrentId),
'showCreateTorrent': (paths) => controllers.torrentList.showCreateTorrent(paths),
'toggleCreateTorrentAdvanced': () => controllers.torrentList.toggleCreateTorrentAdvanced(),
'createTorrent': (options) => controllers.torrentList.createTorrent(options),
'toggleTorrent': (infoHash) => controllers.torrentList.toggleTorrent(infoHash),
'toggleTorrentFile': (infoHash, index) => controllers.torrentList.toggleTorrentFile(infoHash, index),
'confirmDeleteTorrent': (infoHash, deleteData) => controllers.torrentList.confirmDeleteTorrent(infoHash, deleteData),
'deleteTorrent': (infoHash, deleteData) => controllers.torrentList.deleteTorrent(infoHash, deleteData),
'toggleSelectTorrent': (infoHash) => controllers.torrentList.toggleSelectTorrent(infoHash),
'openTorrentContextMenu': (infoHash) => controllers.torrentList.openTorrentContextMenu(infoHash),
'startTorrentingSummary': (torrentSummary) =>
controllers.torrentList.startTorrentingSummary(torrentSummary),
// Playback
'playFile': (infoHash, index) => controllers.playback.playFile(infoHash, index),
'playPause': () => controllers.playback.playPause(),
'skip': (time) => controllers.playback.skip(time),
'skipTo': (time) => controllers.playback.skipTo(time),
'changePlaybackRate': (dir) => controllers.playback.changePlaybackRate(dir),
'changeVolume': (delta) => controllers.playback.changeVolume(delta),
'setVolume': (vol) => controllers.playback.setVolume(vol),
'openItem': (infoHash, index) => controllers.playback.openItem(infoHash, index),
// Subtitles
'openSubtitles': () => controllers.subtitles.openSubtitles(),
'selectSubtitle': (index) => controllers.subtitles.selectSubtitle(index),
'toggleSubtitlesMenu': () => controllers.subtitles.toggleSubtitlesMenu(),
'checkForSubtitles': () => controllers.subtitles.checkForSubtitles(),
'addSubtitles': (files, autoSelect) => controllers.subtitles.addSubtitles(files, autoSelect),
// Local media: <video>, <audio>, VLC
'mediaStalled': () => controllers.media.mediaStalled(),
'mediaError': (err) => controllers.media.mediaError(err),
'mediaSuccess': () => controllers.media.mediaSuccess(),
'mediaTimeUpdate': () => controllers.media.mediaTimeUpdate(),
'mediaMouseMoved': () => controllers.media.mediaMouseMoved(),
'vlcPlay': () => controllers.media.vlcPlay(),
'vlcNotFound': () => controllers.media.vlcNotFound(),
// Remote casting: Chromecast, Airplay, etc
'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType),
'selectCastDevice': (index) => lazyLoadCast().selectDevice(index),
'stopCasting': () => lazyLoadCast().stop(),
// Preferences screen
'preferences': () => controllers.prefs.show(),
'updatePreferences': (key, value) => controllers.prefs.update(key, value),
// Update (check for new versions on Linux, where there's no auto updater)
'updateAvailable': (version) => controllers.update.updateAvailable(version),
'skipVersion': (version) => controllers.update.skipVersion(version),
// Navigation between screens (back, forward, ESC, etc)
'exitModal': () => { state.modal = null },
'backToList': backToList,
'escapeBack': escapeBack,
'back': () => state.location.back(),
'forward': () => state.location.forward(),
// Controlling the window
'setDimensions': setDimensions,
'toggleFullScreen': (setTo) => ipcRenderer.send('toggleFullScreen', setTo),
'setTitle': (title) => { state.window.title = title },
// Everything else
'onOpen': onOpen,
'error': onError,
'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err),
'saveState': () => State.save(state),
'saveStateThrottled': () => State.saveThrottled(state),
'update': () => {} // No-op, just trigger an update
}
// Events from the UI never modify state directly. Instead they call dispatch()
function dispatch (action, ...args) {
// Log dispatch calls, for debugging
if (!['mediaMouseMoved', 'mediaTimeUpdate'].includes(action)) {
console.log('dispatch: %s %o', action, args)
}
var handler = dispatchHandlers[action]
if (handler) handler(...args)
else console.error('Missing dispatch handler: ' + action)
// Update the virtual-dom, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' ||
controllers.playback.showOrHidePlayerControls()) {
update()
}
}
// Listen to events from the main and webtorrent processes
function setupIpc () {
ipcRenderer.on('log', (e, ...args) => console.log(...args))
ipcRenderer.on('error', (e, ...args) => console.error(...args))
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
ipcRenderer.on('fullscreenChanged', onFullscreenChanged)
var tc = controllers.torrent
ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args))
ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args))
ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args))
ipcRenderer.on('wt-progress', (e, ...args) => tc.torrentProgress(...args))
ipcRenderer.on('wt-file-modtimes', (e, ...args) => tc.torrentFileModtimes(...args))
ipcRenderer.on('wt-file-saved', (e, ...args) => tc.torrentFileSaved(...args))
ipcRenderer.on('wt-poster', (e, ...args) => tc.torrentPosterSaved(...args))
ipcRenderer.on('wt-audio-metadata', (e, ...args) => tc.torrentAudioMetadata(...args))
ipcRenderer.on('wt-server-running', (e, ...args) => tc.torrentServerRunning(...args))
ipcRenderer.on('wt-uncaught-error', (e, err) => telemetry.logUncaughtError('webtorrent', err))
ipcRenderer.send('ipcReady')
State.on('savedState', () => ipcRenderer.send('savedState'))
}
// Quits any modal popovers and returns to the torrent list screen
function backToList () {
// Exit any modals and screens with a back button
state.modal = null
state.location.backToFirst(function () {
// If we were already on the torrent list, scroll to the top
var contentTag = document.querySelector('.content')
if (contentTag) contentTag.scrollTop = 0
// Work around virtual-dom issue: it doesn't expose its redraw function,
// and only redraws on requestAnimationFrame(). That means when the user
// closes the window (hide window / minimize to tray) and we want to pause
// the video, we update the vdom but it keeps playing until you reopen!
var mediaTag = document.querySelector('video,audio')
if (mediaTag) mediaTag.pause()
})
}
// Quits modals, full screen, or goes back. Happens when the user hits ESC
function escapeBack () {
if (state.modal) {
dispatch('exitModal')
} else if (state.window.isFullScreen) {
dispatch('toggleFullScreen')
} else {
dispatch('back')
}
}
// Starts all torrents that aren't paused on program startup
function resumeTorrents () {
state.saved.torrents
.filter((torrentSummary) => torrentSummary.status !== 'paused')
.forEach((torrentSummary) => controllers.torrentList.startTorrentingSummary(torrentSummary))
}
// Set window dimensions to match video dimensions or fill the screen
function setDimensions (dimensions) {
// Don't modify the window size if it's already maximized
if (electron.remote.getCurrentWindow().isMaximized()) {
state.window.bounds = null
return
}
// Save the bounds of the window for later. See restoreBounds()
state.window.bounds = {
x: window.screenX,
y: window.screenY,
width: window.outerWidth,
height: window.outerHeight
}
state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
// Limit window size to screen size
var screenWidth = window.screen.width
var screenHeight = window.screen.height
var aspectRatio = dimensions.width / dimensions.height
var scaleFactor = Math.min(
Math.min(screenWidth / dimensions.width, 1),
Math.min(screenHeight / dimensions.height, 1)
)
var width = Math.max(
Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH
)
var height = Math.max(
Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT
)
ipcRenderer.send('setAspectRatio', aspectRatio)
ipcRenderer.send('setBounds', {x: null, y: null, width, height})
state.playing.aspectRatio = aspectRatio
}
// Called when the user adds files (.torrent, files to seed, subtitles) to the app
// via any method (drag-drop, drag to app icon, command line)
function onOpen (files) {
if (!Array.isArray(files)) files = [ files ]
if (state.modal) {
state.modal = null
}
var subtitles = files.filter(controllers.subtitles.isSubtitle)
if (state.location.url() === 'home' || subtitles.length === 0) {
if (files.every(TorrentPlayer.isTorrent)) {
if (state.location.url() !== 'home') {
backToList()
}
// All .torrent files? Add them.
files.forEach((file) => controllers.torrentList.addTorrent(file))
} else {
// Show the Create Torrent screen. Let's seed those files.
controllers.torrentList.showCreateTorrent(files)
}
} else if (state.location.url() === 'player') {
controllers.subtitles.addSubtitles(subtitles, true)
}
update()
}
function onError (err) {
console.error(err.stack || err)
sound.play('ERROR')
state.errors.push({
time: new Date().getTime(),
message: err.message || err
})
update()
}
function onPaste (e) {
if (e.target.tagName.toLowerCase() === 'input') return
var torrentIds = electron.clipboard.readText().split('\n')
torrentIds.forEach(function (torrentId) {
torrentId = torrentId.trim()
if (torrentId.length === 0) return
controllers.torrentList.addTorrent(torrentId)
})
update()
}
function onFocus (e) {
state.window.isFocused = true
state.dock.badge = 0
update()
}
function onBlur () {
state.window.isFocused = false
update()
}
function onVisibilityChange () {
state.window.isVisible = !document.webkitHidden
}
function onFullscreenChanged (e, isFullScreen) {
state.window.isFullScreen = isFullScreen
if (!isFullScreen) {
// Aspect ratio gets reset in fullscreen mode, so restore it (OS X)
ipcRenderer.send('setAspectRatio', state.playing.aspectRatio)
}
update()
}

View File

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

View File

@@ -1,149 +0,0 @@
module.exports = CreateTorrentPage
var createTorrent = require('create-torrent')
var path = require('path')
var prettyBytes = require('prettier-bytes')
var {dispatch, dispatcher} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function CreateTorrentPage (state) {
var info = state.location.current()
// Preprocess: exclude .DS_Store and other dotfiles
var files = info.files
.filter((f) => !f.name.startsWith('.'))
.map((f) => ({name: f.name, path: f.path, size: f.size}))
if (files.length === 0) return CreateTorrentErrorPage()
// First, extract the base folder that the files are all in
var pathPrefix = info.folderPath
if (!pathPrefix) {
pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
}
// Sanity check: show the number of files and total size
var numFiles = files.length
var totalBytes = files
.map((f) => f.size)
.reduce((a, b) => a + b, 0)
var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
// Then, use the name of the base folder (or sole file, for a single file torrent)
// as the default name. Show all files relative to the base folder.
var defaultName, basePath
if (files.length === 1) {
// Single file torrent: /a/b/foo.jpg -> torrent name "foo.jpg", path "/a/b"
defaultName = files[0].name
basePath = pathPrefix
} else {
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name "b", path "/a"
defaultName = path.basename(pathPrefix)
basePath = path.dirname(pathPrefix)
}
var maxFileElems = 100
var fileElems = files.slice(0, maxFileElems).map(function (file) {
var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path)
return hx`<div>${relativePath}</div>`
})
if (files.length > maxFileElems) {
fileElems.push(hx`<div>+ ${maxFileElems - files.length} more</div>`)
}
var trackers = createTorrent.announceList.join('\n')
var collapsedClass = info.showAdvanced ? 'expanded' : 'collapsed'
return hx`
<div class='create-torrent'>
<h2>Create torrent ${defaultName}</h2>
<p class="torrent-info">
${torrentInfo}
</p>
<p class='torrent-attribute'>
<label>Path:</label>
<div class='torrent-attribute'>${pathPrefix}</div>
</p>
<div class='expand-collapse ${collapsedClass}'
onclick=${dispatcher('toggleCreateTorrentAdvanced')}>
${info.showAdvanced ? 'Basic' : 'Advanced'}
</div>
<div class="create-torrent-advanced ${collapsedClass}">
<p class='torrent-attribute'>
<label>Comment:</label>
<textarea class='torrent-attribute torrent-comment'></textarea>
</p>
<p class='torrent-attribute'>
<label>Trackers:</label>
<textarea class='torrent-attribute torrent-trackers'>${trackers}</textarea>
</p>
<p class='torrent-attribute'>
<label>Private:</label>
<input type='checkbox' class='torrent-is-private' value='torrent-is-private'>
</p>
<p class='torrent-attribute'>
<label>Files:</label>
<div>${fileElems}</div>
</p>
</div>
<p class="float-right">
<button class='button-flat light' onclick=${dispatcher('back')}>Cancel</button>
<button class='button-raised' onclick=${handleOK}>Create Torrent</button>
</p>
</div>
`
function handleOK () {
var announceList = document.querySelector('.torrent-trackers').value
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
var isPrivate = document.querySelector('.torrent-is-private').checked
var comment = document.querySelector('.torrent-comment').value.trim()
var options = {
// We can't let the user choose their own name if we want WebTorrent
// to use the files in place rather than creating a new folder.
// If we ever want to add support for that:
// name: document.querySelector('.torrent-name').value
name: defaultName,
path: basePath,
files: files,
announce: announceList,
private: isPrivate,
comment: comment
}
dispatch('createTorrent', options)
}
}
function CreateTorrentErrorPage () {
return hx`
<div class='create-torrent'>
<h2>Create torrent</h2>
<p class="torrent-info">
<p>
Sorry, you must select at least one file that is not a hidden file.
</p>
<p>
Hidden files, starting with a . character, are not included.
</p>
</p>
<p class="float-right">
<button class='button-flat light' onclick=${dispatcher('back')}>
Cancel
</button>
</p>
</div>
`
}
// 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,48 +0,0 @@
module.exports = Header
var {dispatcher} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function Header (state) {
return hx`
<div class='header'>
${getTitle()}
<div class='nav left float-left'>
<i.icon.back
class=${state.location.hasBack() ? '' : 'disabled'}
title='Back'
onclick=${dispatcher('back')}>
chevron_left
</i>
<i.icon.forward
class=${state.location.hasForward() ? '' : 'disabled'}
title='Forward'
onclick=${dispatcher('forward')}>
chevron_right
</i>
</div>
<div class='nav right float-right'>
${getAddButton()}
</div>
</div>
`
function getTitle () {
if (process.platform === 'darwin') {
return hx`<div class='title ellipsis'>${state.window.title}</div>`
}
}
function getAddButton () {
if (state.location.url() === 'home') {
return hx`
<i
class='icon add'
title='Add torrent'
onclick=${dispatcher('openFiles')}>
add
</i>
`
}
}
}

View File

@@ -1,29 +0,0 @@
module.exports = OpenTorrentAddressModal
var {dispatch, dispatcher} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function OpenTorrentAddressModal (state) {
return hx`
<div class='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 class='float-right'>
<button class='button button-flat' onclick=${dispatcher('exitModal')}>Cancel</button>
<button class='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')
dispatch('addTorrent', document.querySelector('#add-torrent-url').value)
}

View File

@@ -1,640 +0,0 @@
module.exports = Player
var Bitfield = require('bitfield')
var prettyBytes = require('prettier-bytes')
var zeroFill = require('zero-fill')
var hx = require('../lib/hx')
var TorrentSummary = require('../lib/torrent-summary')
var {dispatch, dispatcher} = require('../lib/dispatcher')
// Shows a streaming video player. Standard features + Chromecast + Airplay
function Player (state) {
// Show the video as large as will fit in the window, play immediately
// If the video is on Chromecast or Airplay, show a title screen instead
var showVideo = state.playing.location === 'local'
return hx`
<div
class='player'
onwheel=${handleVolumeWheel}
onmousemove=${dispatcher('mediaMouseMoved')}>
${showVideo ? renderMedia(state) : renderCastScreen(state)}
${renderPlayerControls(state)}
</div>
`
}
// Handles volume change by wheel
function handleVolumeWheel (e) {
dispatch('changeVolume', (-e.deltaY | e.deltaX) / 500)
}
function renderMedia (state) {
if (!state.server) return
// Unfortunately, play/pause can't be done just by modifying HTML.
// Instead, grab the DOM node and play/pause it if necessary
// Get the <video> or <audio> tag
var mediaElement = document.querySelector(state.playing.type)
if (mediaElement !== null) {
if (state.playing.isPaused && !mediaElement.paused) {
mediaElement.pause()
} else if (!state.playing.isPaused && mediaElement.paused) {
mediaElement.play()
}
// When the user clicks or drags on the progress bar, jump to that position
if (state.playing.jumpToTime != null) {
mediaElement.currentTime = state.playing.jumpToTime
state.playing.jumpToTime = null
}
if (state.playing.playbackRate !== mediaElement.playbackRate) {
mediaElement.playbackRate = state.playing.playbackRate
}
// Recover previous volume
if (state.previousVolume !== null && isFinite(state.previousVolume)) {
mediaElement.volume = state.previousVolume
state.previousVolume = null
}
// Set volume
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
mediaElement.volume = state.playing.setVolume
state.playing.setVolume = null
}
// Switch to the newly added subtitle track, if available
var tracks = mediaElement.textTracks || []
for (var j = 0; j < tracks.length; j++) {
var isSelectedTrack = j === state.playing.subtitles.selectedIndex
tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden'
}
// Save video position
var file = state.getPlayingFileSummary()
file.currentTime = state.playing.currentTime = mediaElement.currentTime
file.duration = state.playing.duration = mediaElement.duration
// Save selected subtitle
if (state.playing.subtitles.selectedIndex !== -1) {
var index = state.playing.subtitles.selectedIndex
file.selectedSubtitle = state.playing.subtitles.tracks[index].filePath
} else if (file.selectedSubtitle != null) {
delete file.selectedSubtitle
}
state.playing.volume = mediaElement.volume
}
// Add subtitles to the <video> tag
var trackTags = []
if (state.playing.subtitles.selectedIndex >= 0) {
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) {
var track = state.playing.subtitles.tracks[i]
var isSelected = state.playing.subtitles.selectedIndex === i
trackTags.push(hx`
<track
${isSelected ? 'default' : ''}
label=${track.label}
type='subtitles'
src=${track.buffer}>
`)
}
}
// Create the <audio> or <video> tag
var mediaTag = hx`
<div
src='${state.server.localURL}'
ondblclick=${dispatcher('toggleFullScreen')}
onloadedmetadata=${onLoadedMetadata}
onended=${onEnded}
onstalling=${dispatcher('mediaStalled')}
onerror=${dispatcher('mediaError')}
ontimeupdate=${dispatcher('mediaTimeUpdate')}
onencrypted=${dispatcher('mediaEncrypted')}
oncanplay=${onCanPlay}>
${trackTags}
</div>
`
mediaTag.tagName = state.playing.type // conditional tag name
// Show the media.
return hx`
<div
class='letterbox'
onmousemove=${dispatcher('mediaMouseMoved')}>
${mediaTag}
${renderOverlay(state)}
</div>
`
// As soon as we know the video dimensions, resize the window
function onLoadedMetadata (e) {
if (state.playing.type !== 'video') return
var video = e.target
var dimensions = {
width: video.videoWidth,
height: video.videoHeight
}
dispatch('setDimensions', dimensions)
}
// When the video completes, pause the video instead of looping
function onEnded (e) {
state.playing.isPaused = true
}
function onCanPlay (e) {
var elem = e.target
if (state.playing.type === 'video' &&
elem.webkitVideoDecodedByteCount === 0) {
dispatch('mediaError', 'Video codec unsupported')
} else if (elem.webkitAudioDecodedByteCount === 0) {
dispatch('mediaError', 'Audio codec unsupported')
} else {
dispatch('mediaSuccess')
elem.play()
}
}
}
function renderOverlay (state) {
var elems = []
var audioMetadataElem = renderAudioMetadata(state)
var spinnerElem = renderLoadingSpinner(state)
if (audioMetadataElem) elems.push(audioMetadataElem)
if (spinnerElem) elems.push(spinnerElem)
// Video fills the window, centered with black bars if necessary
// Audio gets a static poster image and a summary of the file metadata.
var style
if (state.playing.type === 'audio') {
style = { backgroundImage: cssBackgroundImagePoster(state) }
} else if (elems.length !== 0) {
style = { backgroundImage: cssBackgroundImageDarkGradient() }
} else {
// Video playing, so no spinner. No overlay needed
return
}
return hx`
<div class='media-overlay-background' style=${style}>
<div class='media-overlay'>${elems}</div>
</div>
`
}
function renderAudioMetadata (state) {
var fileSummary = state.getPlayingFileSummary()
if (!fileSummary.audioInfo) return
var info = fileSummary.audioInfo
// Get audio track info
var title = info.title
if (!title) {
title = fileSummary.name
}
var artist = info.artist && info.artist[0]
var album = info.album
if (album && info.year && !album.includes(info.year)) {
album += ' (' + info.year + ')'
}
var track
if (info.track && info.track.no && info.track.of) {
track = info.track.no + ' of ' + info.track.of
}
// Show a small info box in the middle of the screen with title/album/etc
var elems = []
if (artist) {
elems.push(hx`
<div class='audio-artist'>
<label>Artist</label>${artist}
</div>
`)
}
if (album) {
elems.push(hx`
<div class='audio-album'>
<label>Album</label>${album}
</div>
`)
}
if (track) {
elems.push(hx`
<div class='audio-track'>
<label>Track</label>${track}
</div>
`)
}
// Align the title with the other info, if available. Otherwise, center title
var emptyLabel = hx`<label></label>`
elems.unshift(hx`
<div class='audio-title'>
${elems.length ? emptyLabel : undefined}${title}
</div>
`)
return hx`<div class='audio-metadata'>${elems}</div>`
}
function renderLoadingSpinner (state) {
if (state.playing.isPaused) return
var isProbablyStalled = state.playing.isStalled ||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000)
if (!isProbablyStalled) return
var prog = state.getPlayingTorrentSummary().progress || {}
var fileProgress = 0
if (prog.files) {
var file = prog.files[state.playing.fileIndex]
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
}
return hx`
<div class='media-stalled'>
<div class='loading-spinner'>&nbsp;</div>
<div class='loading-status ellipsis'>
<span class='progress'>${fileProgress}%</span> downloaded,
<span>↓ ${prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span>↑ ${prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div>
</div>
`
}
function renderCastScreen (state) {
var castIcon, castType, isCast
if (state.playing.location.startsWith('chromecast')) {
castIcon = 'cast_connected'
castType = 'Chromecast'
isCast = true
} else if (state.playing.location.startsWith('airplay')) {
castIcon = 'airplay'
castType = 'AirPlay'
isCast = true
} else if (state.playing.location.startsWith('dlna')) {
castIcon = 'tv'
castType = 'DLNA'
isCast = true
} else if (state.playing.location === 'vlc') {
castIcon = 'tv'
castType = 'VLC'
isCast = false
} else if (state.playing.location === 'error') {
castIcon = 'error_outline'
castType = 'Error'
isCast = false
}
var isStarting = state.playing.location.endsWith('-pending')
var castName = state.playing.castName
var castStatus
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
else castStatus = ''
// Show a nice title image, if possible
var style = {
backgroundImage: cssBackgroundImagePoster(state)
}
return hx`
<div class='letterbox' style=${style}>
<div class='cast-screen'>
<i class='icon'>${castIcon}</i>
<div class='cast-type'>${castType}</div>
<div class='cast-status'>${castStatus}</div>
</div>
</div>
`
}
function renderCastOptions (state) {
if (!state.devices.castMenu) return
var {location, devices} = state.devices.castMenu
var player = state.devices[location]
var items = devices.map(function (device, ix) {
var isSelected = player.device === device
var name = device.name
return hx`
<li onclick=${dispatcher('selectCastDevice', ix)}>
<i.icon>${isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
${name}
</li>
`
})
return hx`
<ul.options-list>
${items}
</ul>
`
}
function renderSubtitlesOptions (state) {
var subtitles = state.playing.subtitles
if (!subtitles.tracks.length || !subtitles.showMenu) return
var items = subtitles.tracks.map(function (track, ix) {
var isSelected = state.playing.subtitles.selectedIndex === ix
return hx`
<li onclick=${dispatcher('selectSubtitle', ix)}>
<i.icon>${'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
${track.label}
</li>
`
})
var noneSelected = state.playing.subtitles.selectedIndex === -1
var noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked')
return hx`
<ul.options-list>
${items}
<li onclick=${dispatcher('selectSubtitle', -1)}>
<i.icon>${noneClass}</i>
None
</li>
</ul>
`
}
function renderPlayerControls (state) {
var positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
var captionsClass = state.playing.subtitles.tracks.length === 0
? 'disabled'
: state.playing.subtitles.selectedIndex >= 0
? 'active'
: ''
var elements = [
hx`
<div class='playback-bar'>
${renderLoadingBar(state)}
<div
class='playback-cursor'
style=${playbackCursorStyle}>
</div>
<div
class='scrub-bar'
draggable='true'
ondragstart=${handleDragStart}
onclick=${handleScrub},
ondrag=${handleScrub}>
</div>
</div>
`,
hx`
<i class='icon play-pause float-left' onclick=${dispatcher('playPause')}>
${state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>
`,
hx`
<i
class='icon fullscreen float-right'
onclick=${dispatcher('toggleFullScreen')}>
${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
</i>
`
]
if (state.playing.type === 'video') {
// show closed captions icon
elements.push(hx`
<i.icon.closed-caption.float-right
class=${captionsClass}
onclick=${handleSubtitles}>
closed_caption
</i>
`)
}
// If we've detected a Chromecast or AppleTV, the user can play video there
var castTypes = ['chromecast', 'airplay', 'dlna']
var isCastingAnywhere = castTypes.some(
(castType) => state.playing.location.startsWith(castType))
// Add the cast buttons. Icons for each cast type, connected/disconnected:
var buttonIcons = {
'chromecast': {true: 'cast_connected', false: 'cast'},
'airplay': {true: 'airplay', false: 'airplay'},
'dlna': {true: 'tv', false: 'tv'}
}
castTypes.forEach(function (castType) {
// Do we show this button (eg. the Chromecast button) at all?
var isCasting = state.playing.location.startsWith(castType)
var player = state.devices[castType]
if ((!player || player.getDevices().length === 0) && !isCasting) return
// Show the button. Three options for eg the Chromecast button:
var buttonClass, buttonHandler
if (isCasting) {
// Option 1: we are currently connected to Chromecast. Button stops the cast.
buttonClass = 'active'
buttonHandler = dispatcher('stopCasting')
} else if (isCastingAnywhere) {
// Option 2: we are currently connected somewhere else. Button disabled.
buttonClass = 'disabled'
buttonHandler = undefined
} else {
// Option 3: we are not connected anywhere. Button opens Chromecast menu.
buttonClass = ''
buttonHandler = dispatcher('toggleCastMenu', castType)
}
var buttonIcon = buttonIcons[castType][isCasting]
elements.push(hx`
<i.icon.device.float-right
class=${buttonClass}
onclick=${buttonHandler}>
${buttonIcon}
</i>
`)
})
// Render volume slider
var volume = state.playing.volume
var volumeIcon = 'volume_' + (
volume === 0 ? 'off'
: volume < 0.3 ? 'mute'
: volume < 0.6 ? 'down'
: 'up')
var volumeStyle = {
background: '-webkit-gradient(linear, left top, right top, ' +
'color-stop(' + (volume * 100) + '%, #eee), ' +
'color-stop(' + (volume * 100) + '%, #727272))'
}
elements.push(hx`
<div class='volume float-left'>
<i
class='icon volume-icon float-left'
onmousedown=${handleVolumeMute}>
${volumeIcon}
</i>
<input
class='volume-slider float-right'
type='range' min='0' max='1' step='0.05'
value=${volumeChanging !== false ? volumeChanging : volume}
onmousedown=${handleVolumeScrub}
onmouseup=${handleVolumeScrub}
onmousemove=${handleVolumeScrub}
style=${volumeStyle}
/>
</div>
`)
// Show video playback progress
var currentTimeStr = formatTime(state.playing.currentTime)
var durationStr = formatTime(state.playing.duration)
elements.push(hx`
<span class='time float-left'>
${currentTimeStr} / ${durationStr}
</span>
`)
// render playback rate
if (state.playing.playbackRate !== 1) {
elements.push(hx`
<span class='rate float-left'>
${state.playing.playbackRate}x
</span>
`)
}
return hx`
<div class='controls'>
${elements}
${renderCastOptions(state)}
${renderSubtitlesOptions(state)}
</div>
`
function handleDragStart (e) {
// Prevent the cursor from changing, eg to a green + icon on Mac
if (e.dataTransfer) {
var dt = e.dataTransfer
dt.effectAllowed = 'none'
}
}
// Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) {
if (!e.clientX) return
dispatch('mediaMouseMoved')
var windowWidth = document.querySelector('body').clientWidth
var fraction = e.clientX / windowWidth
var position = fraction * state.playing.duration /* seconds */
dispatch('skipTo', position)
}
// Handles volume muting and Unmuting
function handleVolumeMute (e) {
if (state.playing.volume === 0.0) {
dispatch('setVolume', 1.0)
} else {
dispatch('setVolume', 0.0)
}
}
// Handles volume slider scrub
function handleVolumeScrub (e) {
switch (e.type) {
case 'mouseup':
volumeChanging = false
dispatch('setVolume', e.offsetX / 50)
break
case 'mousedown':
volumeChanging = this.value
break
case 'mousemove':
// only change if move was started by click
if (volumeChanging !== false) {
volumeChanging = this.value
}
break
}
}
function handleSubtitles (e) {
if (!state.playing.subtitles.tracks.length || e.ctrlKey || e.metaKey) {
// if no subtitles available select it
dispatch('openSubtitles')
} else {
dispatch('toggleSubtitlesMenu')
}
}
}
// lets scrub without sending to volume backend
var volumeChanging = false
// Renders the loading bar. Shows which parts of the torrent are loaded, which
// can be "spongey" / non-contiguous
function renderLoadingBar (state) {
var torrentSummary = state.getPlayingTorrentSummary()
if (!torrentSummary.progress) {
return []
}
// Find all contiguous parts of the torrent which are loaded
var prog = torrentSummary.progress
var fileProg = prog.files[state.playing.fileIndex]
var parts = []
var lastPiecePresent = false
for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
var partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
if (partPresent && !lastPiecePresent) {
parts.push({start: i - fileProg.startPiece, count: 1})
} else if (partPresent) {
parts[parts.length - 1].count++
}
lastPiecePresent = partPresent
}
// Output some bars to show which parts of the file are loaded
return hx`
<div class='loading-bar'>
${parts.map(function (part) {
var style = {
left: (100 * part.start / fileProg.numPieces) + '%',
width: (100 * part.count / fileProg.numPieces) + '%'
}
return hx`<div class='loading-bar-part' style=${style}></div>`
})}
</div>
`
}
// Returns the CSS background-image string for a poster image + dark vignette
function cssBackgroundImagePoster (state) {
var torrentSummary = state.getPlayingTorrentSummary()
var posterPath = TorrentSummary.getPosterPath(torrentSummary)
if (!posterPath) return ''
return cssBackgroundImageDarkGradient() + `, url(${posterPath})`
}
function cssBackgroundImageDarkGradient () {
return 'radial-gradient(circle at center, ' +
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
}
function formatTime (time) {
if (typeof time !== 'number' || Number.isNaN(time)) {
return '0:00'
}
var hours = Math.floor(time / 3600)
var minutes = Math.floor(time % 3600 / 60)
if (hours > 0) {
minutes = zeroFill(2, minutes)
}
var seconds = zeroFill(2, Math.floor(time % 60))
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
}

View File

@@ -1,102 +0,0 @@
module.exports = Preferences
var hx = require('../lib/hx')
var {dispatch} = require('../lib/dispatcher')
var remote = require('electron').remote
var dialog = remote.dialog
function Preferences (state) {
return hx`
<div class='preferences'>
${renderGeneralSection(state)}
</div>
`
}
function renderGeneralSection (state) {
return renderSection({
title: 'General',
description: '',
icon: 'settings'
}, [
renderDownloadDirSelector(state)
])
}
function renderDownloadDirSelector (state) {
return renderFileSelector({
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) {
setStateValue('downloadPath', filePath)
})
}
// 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 : hx`
<div class='help text'>
<i.icon>help_outline</i>${definition.description}
</div>
`
return hx`
<section class='section preferences-panel'>
<div class='section-container'>
<div class='section-heading'>
<i.icon>${definition.icon}</i>${definition.title}
</div>
${helpElem}
<div class='section-body'>
${controls}
</div>
</div>
</section>
`
}
// 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) {
return hx`
<div class='control-group'>
<div class='controls'>
<label class='control-label'>
<div class='preference-title'>${definition.label}</div>
<div class='preference-description'>${definition.description}</div>
</label>
<div class='controls'>
<input type='text' class='file-picker-text'
id=${definition.property}
disabled='disabled'
value=${value} />
<button class='btn' onclick=${handleClick}>
<i.icon>folder_open</i>
</button>
</div>
</div>
</div>
`
function handleClick () {
dialog.showOpenDialog(remote.getCurrentWindow(), definition.options, function (filenames) {
if (!Array.isArray(filenames)) return
callback(filenames[0])
})
}
}
function setStateValue (property, value) {
dispatch('updatePreferences', property, value)
}

View File

@@ -1,26 +0,0 @@
module.exports = RemoveTorrentModal
var {dispatch, dispatcher} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function RemoveTorrentModal (state) {
var message = state.modal.deleteData
? 'Are you sure you want to remove this torrent from the list and delete the data file?'
: 'Are you sure you want to remove this torrent from the list?'
var buttonText = state.modal.deleteData ? 'Remove Data' : 'Remove'
return hx`
<div>
<p><strong>${message}</strong></p>
<p class='float-right'>
<button class='button button-flat' onclick=${dispatcher('exitModal')}>Cancel</button>
<button class='button button-raised' onclick=${handleRemove}>${buttonText}</button>
</p>
</div>
`
function handleRemove () {
dispatch('deleteTorrent', state.modal.infoHash, state.modal.deleteData)
dispatch('exitModal')
}
}

View File

@@ -1,296 +0,0 @@
module.exports = TorrentList
var prettyBytes = require('prettier-bytes')
var hx = require('../lib/hx')
var TorrentSummary = require('../lib/torrent-summary')
var TorrentPlayer = require('../lib/torrent-player')
var {dispatcher} = require('../lib/dispatcher')
function TorrentList (state) {
var torrentRows = state.saved.torrents.map(
(torrentSummary) => renderTorrent(torrentSummary)
)
return hx`
<div class='torrent-list'>
${torrentRows}
<div class='torrent-placeholder'>
<span class='ellipsis'>Drop a torrent file here or paste a magnet link</span>
</div>
</div>`
function renderTorrent (torrentSummary) {
var infoHash = torrentSummary.infoHash
var isSelected = infoHash && state.selectedInfoHash === infoHash
// Background image: show some nice visuals, like a frame from the movie, if possible
var style = {}
if (torrentSummary.posterFileName) {
var gradient = isSelected
? 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 100%)'
: 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)'
var posterPath = TorrentSummary.getPosterPath(torrentSummary)
style.backgroundImage = gradient + `, url('${posterPath}')`
}
// Foreground: name of the torrent, basic info like size, play button,
// cast buttons if available, and delete
var 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 (!infoHash) classes.push('disabled')
classes = classes.join(' ')
return hx`
<div style=${style} class=${classes}
oncontextmenu=${infoHash && dispatcher('openTorrentContextMenu', infoHash)}
onclick=${infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
${renderTorrentMetadata(torrentSummary)}
${infoHash ? renderTorrentButtons(torrentSummary) : ''}
${isSelected ? renderTorrentDetails(torrentSummary) : ''}
</div>
`
}
// Show name, download status, % complete
function renderTorrentMetadata (torrentSummary) {
var name = torrentSummary.name || 'Loading torrent...'
var elements = [hx`
<div class='name ellipsis'>${name}</div>
`]
// If it's downloading/seeding then show progress info
var prog = torrentSummary.progress
if (torrentSummary.status !== 'paused' && prog) {
elements.push(hx`
<div class='ellipsis'>
${renderPercentProgress()}
${renderTotalProgress()}
${renderPeers()}
${renderDownloadSpeed()}
${renderUploadSpeed()}
</div>
`)
}
return hx`<div class='metadata'>${elements}</div>`
function renderPercentProgress () {
var progress = Math.floor(100 * prog.progress)
return hx`<span>${progress}%</span>`
}
function renderTotalProgress () {
var downloaded = prettyBytes(prog.downloaded)
var total = prettyBytes(prog.length || 0)
if (downloaded === total) {
return hx`<span>${downloaded}</span>`
} else {
return hx`<span>${downloaded} / ${total}</span>`
}
}
function renderPeers () {
if (prog.numPeers === 0) return
var count = prog.numPeers === 1 ? 'peer' : 'peers'
return hx`<span>${prog.numPeers} ${count}</span>`
}
function renderDownloadSpeed () {
if (prog.downloadSpeed === 0) return
return hx`<span>↓ ${prettyBytes(prog.downloadSpeed)}/s</span>`
}
function renderUploadSpeed () {
if (prog.uploadSpeed === 0) return
return hx`<span>↑ ${prettyBytes(prog.uploadSpeed)}/s</span>`
}
}
// Download button toggles between torrenting (DL/seed) and paused
// Play button starts streaming the torrent immediately, unpausing if needed
function renderTorrentButtons (torrentSummary) {
var infoHash = torrentSummary.infoHash
var playIcon, playTooltip, playClass
if (torrentSummary.playStatus === 'timeout') {
playIcon = 'warning'
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 = renderRadialProgressBar(fraction, 'radial-progress-large')
playClass = 'resume-position'
}
// Only show the play button for torrents that contain playable media
var playButton
if (TorrentPlayer.isPlayableTorrentSummary(torrentSummary)) {
playButton = hx`
<i.button-round.icon.play
title=${playTooltip}
class=${playClass}
onclick=${dispatcher('playFile', infoHash)}>
${playIcon}
</i>
`
}
return hx`
<div class='buttons'>
${positionElem}
${playButton}
<i.button-round.icon.download
class=${torrentSummary.status}
title=${downloadTooltip}
onclick=${dispatcher('toggleTorrent', infoHash)}>
${downloadIcon}
</i>
<i
class='icon delete'
title='Remove torrent'
onclick=${dispatcher('confirmDeleteTorrent', infoHash, false)}>
close
</i>
</div>
`
}
// Show files, per-file download status and play buttons, and so on
function renderTorrentDetails (torrentSummary) {
var filesElement
if (!torrentSummary.files) {
// We don't know what files this torrent contains
var message = torrentSummary.status === 'paused'
? 'Failed to load torrent info. Click the download button to try again...'
: 'Downloading torrent info...'
filesElement = hx`<div class='files warning'>${message}</div>`
} else {
// We do know the files. List them and show download stats for each one
var fileRows = torrentSummary.files
.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) => renderFileRow(torrentSummary, object.file, object.index))
filesElement = hx`
<div class='files'>
<table>
${fileRows}
</table>
</div>
`
}
return hx`
<div class='torrent-details'>
${filesElement}
</div>
`
}
// Show a single torrentSummary file in the details view for a single torrent
function renderFileRow (torrentSummary, file, index) {
// First, find out how much of the file we've downloaded
// Are we even torrenting it?
var isSelected = torrentSummary.selections && torrentSummary.selections[index]
var isDone = false // Are we finished torrenting it?
var progress = ''
if (torrentSummary.progress && torrentSummary.progress.files) {
var fileProg = torrentSummary.progress.files[index]
isDone = 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
var positionElem
if (file.currentTime) {
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
positionElem = renderRadialProgressBar(file.currentTime / file.duration)
}
// Finally, render the file as a table row
var isPlayable = TorrentPlayer.isPlayable(file)
var infoHash = torrentSummary.infoHash
var icon
var handleClick
if (isPlayable) {
icon = 'play_arrow' /* playable? add option to play */
handleClick = dispatcher('playFile', infoHash, index)
} else {
icon = 'description' /* file icon, opens in OS default app */
handleClick = dispatcher('openItem', infoHash, index)
}
var rowClass = ''
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
return hx`
<tr onclick=${handleClick}>
<td class='col-icon ${rowClass}'>
${positionElem}
<i class='icon'>${icon}</i>
</td>
<td class='col-name ${rowClass}'>
${file.name}
</td>
<td class='col-progress ${rowClass}'>
${isSelected ? progress : ''}
</td>
<td class='col-size ${rowClass}'>
${prettyBytes(file.length)}
</td>
<td class='col-select'
onclick=${dispatcher('toggleTorrentFile', infoHash, index)}>
<i class='icon'>${isSelected ? 'close' : 'add'}</i>
</td>
</tr>
`
}
}
function renderRadialProgressBar (fraction, cssClass) {
var rotation = 360 * fraction
var transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
var transformFix = {transform: 'rotate(' + rotation + 'deg)'}
return hx`
<div class="radial-progress ${cssClass}">
<div class="circle">
<div class="mask full" style=${transformFill}>
<div class="fill" style=${transformFill}></div>
</div>
<div class="mask half">
<div class="fill" style=${transformFill}></div>
<div class="fill fix" style=${transformFix}></div>
</div>
</div>
<div class="inset"></div>
</div>
`
}

View File

@@ -1,39 +0,0 @@
module.exports = UnsupportedMediaModal
var electron = require('electron')
var {dispatch, dispatcher} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function UnsupportedMediaModal (state) {
var err = state.modal.error
var message = (err && err.getMessage)
? err.getMessage()
: err
var actionButton = state.modal.vlcInstalled
? hx`<button class="button-raised" onclick=${onPlay}>Play in VLC</button>`
: hx`<button class="button-raised" onclick=${onInstall}>Install VLC</button>`
var vlcMessage = state.modal.vlcNotFound
? 'Couldn\'t run VLC. Please make sure it\'s installed.'
: ''
return hx`
<div>
<p><strong>Sorry, we can't play that file.</strong></p>
<p>${message}</p>
<p class='float-right'>
<button class="button-flat" onclick=${dispatcher('backToList')}>Cancel</button>
${actionButton}
</p>
<p class='error-text'>${vlcMessage}</p>
</div>
`
function onInstall () {
electron.shell.openExternal('http://www.videolan.org/vlc/')
state.modal.vlcInstalled = true // Assume they'll install it successfully
}
function onPlay () {
dispatch('vlcPlay')
}
}

View File

@@ -1,29 +0,0 @@
module.exports = UpdateAvailableModal
var electron = require('electron')
var {dispatch} = require('../lib/dispatcher')
var hx = require('../lib/hx')
function UpdateAvailableModal (state) {
return hx`
<div class='update-available-modal'>
<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 class='float-right'>
<button class='button button-flat' onclick=${handleCancel}>Skip This Release</button>
<button class='button button-raised' onclick=${handleOK}>Show Download Page</button>
</p>
</div>
`
function handleOK () {
electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases')
dispatch('exitModal')
}
function handleCancel () {
dispatch('skipVersion', state.modal.version)
dispatch('exitModal')
}
}

174
src/config.js Normal file
View File

@@ -0,0 +1,174 @@
const appConfig = require('application-config')('WebTorrent')
const path = require('path')
const electron = require('electron')
const arch = require('arch')
const APP_NAME = 'WebTorrent'
const APP_TEAM = 'WebTorrent, LLC'
const APP_VERSION = require('../package.json').version
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()
const UI_HEADER_HEIGHT = 38
const UI_TORRENT_HEIGHT = 100
module.exports = {
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
APP_NAME: APP_NAME,
APP_TEAM: APP_TEAM,
APP_VERSION: APP_VERSION,
APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
CONFIG_PATH: getConfigPath(),
DEFAULT_TORRENTS: [
{
testID: 'bbb',
name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent'
},
{
testID: 'cosmos',
name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent'
},
{
testID: 'sintel',
name: 'Sintel',
posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent'
},
{
testID: 'tears',
name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent'
},
{
testID: 'wired',
name: 'The WIRED CD - Rip. Sample. Mash. Share',
posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent'
}
],
DELAYED_INIT: 3000 /* 3 seconds */,
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
HOME_PAGE_URL: 'https://webtorrent.io',
IS_PORTABLE: IS_PORTABLE,
IS_PRODUCTION: IS_PRODUCTION,
IS_TEST: IS_TEST,
OS_SYSARCH: arch() === 'x64' ? 'x64' : 'ia32',
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
ROOT_PATH: path.join(__dirname, '..'),
STATIC_PATH: path.join(__dirname, '..', 'static'),
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
WINDOW_ABOUT: 'file://' + path.join(__dirname, '..', 'static', 'about.html'),
WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'),
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'),
WINDOW_INITIAL_BOUNDS: {
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
}
function getConfigPath () {
if (IS_PORTABLE) {
return PORTABLE_PATH
} else {
return path.dirname(appConfig.filePath)
}
}
function getDefaultDownloadPath () {
if (IS_PORTABLE) {
return path.join(getConfigPath(), 'Downloads')
} else {
return getPath('downloads')
}
}
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)
}
}
function isTest () {
return process.env.NODE_ENV === 'test'
}
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
}
const fs = require('fs')
try {
// 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) {
return false
}
}
function isProduction () {
if (!process.versions.electron) {
// Node.js process
return false
}
if (process.platform === 'darwin') {
return !/\/Electron\.app\//.test(process.execPath)
}
if (process.platform === 'win32') {
return !/\\electron\.exe$/.test(process.execPath)
}
if (process.platform === 'linux') {
return !/\/electron$/.test(process.execPath)
}
}

View File

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

View File

@@ -2,12 +2,12 @@ module.exports = {
init
}
var electron = require('electron')
const electron = require('electron')
var config = require('../config')
var log = require('./log')
const config = require('../config')
const log = require('./log')
var ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
const ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
'?version=' + config.APP_VERSION +
'&platform=' + process.platform
@@ -26,13 +26,13 @@ var ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
* }
*/
function init () {
var get = require('simple-get')
const get = require('simple-get')
get.concat(ANNOUNCEMENT_URL, onResponse)
}
function onResponse (err, res, data) {
if (err) return log(`Failed to retrieve announcement: ${err.message}`)
if (res.statusCode !== 200) return log('No announcement exists')
if (res.statusCode !== 200) return log('No announcement available')
try {
data = JSON.parse(data.toString())

View File

@@ -6,11 +6,10 @@ module.exports = {
openFiles
}
var electron = require('electron')
const electron = require('electron')
var config = require('../config')
var log = require('./log')
var windows = require('./windows')
const log = require('./log')
const windows = require('./windows')
/**
* Show open dialog to create a single-file torrent.
@@ -18,16 +17,11 @@ var windows = require('./windows')
function openSeedFile () {
if (!windows.main.win) return
log('openSeedFile')
var opts = {
const opts = {
title: 'Select a file for the torrent.',
properties: [ 'openFile' ]
}
setTitle(opts.title)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
showOpenSeed(opts)
}
/*
@@ -38,7 +32,7 @@ function openSeedFile () {
function openSeedDirectory () {
if (!windows.main.win) return
log('openSeedDirectory')
var opts = process.platform === 'darwin'
const opts = process.platform === 'darwin'
? {
title: 'Select a file or folder for the torrent.',
properties: [ 'openFile', 'openDirectory' ]
@@ -47,12 +41,7 @@ function openSeedDirectory () {
title: 'Select a folder for the torrent.',
properties: [ 'openDirectory' ]
}
setTitle(opts.title)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
showOpenSeed(opts)
}
/*
@@ -62,7 +51,7 @@ function openSeedDirectory () {
function openFiles () {
if (!windows.main.win) return
log('openFiles')
var opts = process.platform === 'darwin'
const opts = process.platform === 'darwin'
? {
title: 'Select a file or folder to add.',
properties: [ 'openFile', 'openDirectory' ]
@@ -85,7 +74,7 @@ function openFiles () {
function openTorrentFile () {
if (!windows.main.win) return
log('openTorrentFile')
var opts = {
const opts = {
title: 'Select a .torrent file.',
filters: [{ name: 'Torrent Files', extensions: ['torrent'] }],
properties: [ 'openFile', 'multiSelections' ]
@@ -109,7 +98,7 @@ function openTorrentAddress () {
}
/**
* Dialogs on do not show a title on OS X, so the window title is used instead.
* Dialogs on do not show a title on Mac, so the window title is used instead.
*/
function setTitle (title) {
if (process.platform === 'darwin') {
@@ -118,5 +107,18 @@ function setTitle (title) {
}
function resetTitle () {
setTitle(config.APP_WINDOW_TITLE)
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,24 +4,22 @@ module.exports = {
setBadge
}
var electron = require('electron')
const {app, Menu} = require('electron')
var app = electron.app
var dialog = require('./dialog')
var log = require('./log')
const dialog = require('./dialog')
const log = require('./log')
/**
* Add a right-click menu to the dock icon. (OS X)
* Add a right-click menu to the dock icon. (Mac)
*/
function init () {
if (!app.dock) return
var menu = electron.Menu.buildFromTemplate(getMenuTemplate())
const menu = Menu.buildFromTemplate(getMenuTemplate())
app.dock.setMenu(menu)
}
/**
* Bounce the Downloads stack if `path` is inside the Downloads folder. (OS X)
* Bounce the Downloads stack if `path` is inside the Downloads folder. (Mac)
*/
function downloadFinished (path) {
if (!app.dock) return
@@ -30,12 +28,14 @@ function downloadFinished (path) {
}
/**
* Display string in dock badging area. (OS X)
* Display a counter badge for the app. (Mac, Linux)
*/
function setBadge (text) {
if (!app.dock) return
log(`setBadge: ${text}`)
app.dock.setBadge(String(text))
function setBadge (count) {
if (process.platform === 'darwin' ||
process.platform === 'linux' && app.isUnityRunning()) {
log(`setBadge: ${count}`)
app.setBadgeCount(Number(count))
}
}
function getMenuTemplate () {

View File

@@ -0,0 +1,77 @@
module.exports = {
spawn,
kill,
checkInstall
}
const cp = require('child_process')
const path = require('path')
const vlcCommand = require('vlc-command')
const log = require('./log')
const windows = require('./windows')
// holds a ChildProcess while we're playing a video in an external player, null otherwise
let proc = null
function checkInstall (playerPath, cb) {
// check for VLC if external player has not been specified by the user
// otherwise assume the player is installed
if (playerPath == null) return vlcCommand((err) => cb(!err))
process.nextTick(() => cb(true))
}
function spawn (playerPath, url, title) {
if (playerPath != null) return spawnExternal(playerPath, [url])
// Try to find and use VLC if external player is not specified
vlcCommand(function (err, vlcPath) {
if (err) return windows.main.dispatch('externalPlayerNotFound')
const args = [
'--play-and-exit',
'--video-on-top',
'--quiet',
`--meta-title=${JSON.stringify(title)}`,
url
]
spawnExternal(vlcPath, args)
})
}
function kill () {
if (!proc) return
log('Killing external player, pid ' + proc.pid)
proc.kill('SIGKILL') // kill -9
proc = null
}
function spawnExternal (playerPath, args) {
log('Running external media player:', playerPath + ' ' + args.join(' '))
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
const closeModalTimeout = setTimeout(() =>
windows.main.dispatch('exitModal'), 1000)
proc.on('close', function (code) {
clearTimeout(closeModalTimeout)
if (!proc) return // Killed
log('External player exited with code ', code)
if (code === 0) {
windows.main.dispatch('backToList')
} else {
windows.main.dispatch('externalPlayerNotFound')
}
proc = null
})
proc.on('error', function (e) {
log('External player error', e)
})
}

View File

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

216
src/main/index.js Normal file
View File

@@ -0,0 +1,216 @@
console.time('init')
const electron = require('electron')
const app = electron.app
const parallel = require('run-parallel')
const config = require('../config')
const crashReporter = require('../crash-reporter')
const ipc = require('./ipc')
const log = require('./log')
const menu = require('./menu')
const State = require('../renderer/lib/state')
const windows = require('./windows')
let shouldQuit = false
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) {
// When Electron is running in production mode (packaged app), then run React
// in production mode too.
process.env.NODE_ENV = 'production'
}
if (process.platform === 'win32') {
const squirrelWin32 = require('./squirrel-win32')
shouldQuit = squirrelWin32.handleEvent(argv[0])
argv = argv.filter((arg) => !arg.includes('--squirrel'))
}
if (!shouldQuit && !config.IS_PORTABLE) {
// Prevent multiple instances of app from running at same time. New instances
// 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)
if (shouldQuit) {
app.quit()
}
}
if (!shouldQuit) {
init()
}
function init () {
if (config.IS_PORTABLE) {
const path = require('path')
// Put all user data into the "Portable Settings" folder
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'))
}
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.isQuitting = false
parallel({
appReady: (cb) => app.on('ready', () => cb(null)),
state: (cb) => State.load(cb)
}, onReady)
function onReady (err, results) {
if (err) throw err
isReady = true
windows.main.init(results.state, {hidden: hidden})
windows.webtorrent.init()
menu.init()
// To keep app startup fast, some code is delayed.
setTimeout(delayedInit, config.DELAYED_INIT)
// Report uncaught exceptions
process.on('uncaughtException', (err) => {
console.error(err)
const error = {message: err.message, stack: err.stack}
windows.main.dispatch('uncaughtError', 'main', error)
})
}
app.on('open-file', onOpen)
app.on('open-url', onOpen)
ipc.init()
app.once('will-finish-launching', function () {
crashReporter.init()
})
app.once('ipcReady', function () {
log('Command line args:', argv)
processArgv(argv)
console.timeEnd('init')
})
app.on('before-quit', function (e) {
if (app.isQuitting) return
app.isQuitting = true
e.preventDefault()
windows.main.dispatch('stateSaveImmediate') // try to save state on exit
ipcMain.once('stateSaved', () => app.quit())
setTimeout(() => {
console.error('Saving state took too long. Quitting.')
app.quit()
}, 4000) // quit after 4 secs, at most
})
app.on('activate', function () {
if (isReady) windows.main.show()
})
}
function delayedInit () {
if (app.isQuitting) return
const announcement = require('./announcement')
const dock = require('./dock')
const updater = require('./updater')
announcement.init()
dock.init()
updater.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) {
e.preventDefault()
if (app.ipcReady) {
// Magnet links opened from Chrome won't focus the app without a setTimeout.
// The confirmation dialog Chrome shows causes Chrome to steal back the focus.
// Electron issue: https://github.com/atom/electron/issues/4338
setTimeout(() => windows.main.show(), 100)
processArgv([ torrentId ])
} else {
argv.push(torrentId)
}
}
function onAppOpen (newArgv) {
newArgv = sliceArgv(newArgv)
if (app.ipcReady) {
log('Second app instance opened, but was prevented:', newArgv)
windows.main.show()
processArgv(newArgv)
} else {
argv.push(...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) {
return argv.slice(config.IS_PRODUCTION ? 1
: config.IS_TEST ? 4
: 2)
}
function processArgv (argv) {
let torrentIds = []
argv.forEach(function (arg) {
if (arg === '-n' || arg === '-o' || arg === '-u') {
// Critical path: Only load the 'dialog' package if it is needed
const dialog = require('./dialog')
if (arg === '-n') {
dialog.openSeedDirectory()
} 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')) {
// Ignore Mac launchd "process serial number" argument
// Issue: https://github.com/feross/webtorrent-desktop/issues/214
} else if (arg.startsWith('--')) {
// Ignore Spectron flags
} else if (arg === 'data:,') {
// Ignore weird Spectron argument
} else if (arg !== '.') {
// Ignore '.' argument, which gets misinterpreted as a torrent id, when a
// development copy of WebTorrent is started while a production version is
// running.
torrentIds.push(arg)
}
})
if (torrentIds.length > 0) {
windows.main.dispatch('onOpen', torrentIds)
}
}

218
src/main/ipc.js Normal file
View File

@@ -0,0 +1,218 @@
module.exports = {
init
}
const electron = require('electron')
const app = electron.app
const log = require('./log')
const menu = require('./menu')
const windows = require('./windows')
// Messages from the main process, to be sent once the WebTorrent process starts
const messageQueueMainToWebTorrent = []
function init () {
const ipc = electron.ipcMain
ipc.once('ipcReady', function (e) {
app.ipcReady = true
app.emit('ipcReady')
})
ipc.once('ipcReadyWebTorrent', function (e) {
app.ipcReadyWebTorrent = true
log('sending %d queued messages from the main win to the webtorrent window',
messageQueueMainToWebTorrent.length)
messageQueueMainToWebTorrent.forEach(function (message) {
windows.webtorrent.send(message.name, ...message.args)
log('webtorrent: sent queued %s', message.name)
})
})
/**
* Dialog
*/
ipc.on('openTorrentFile', () => {
const dialog = require('./dialog')
dialog.openTorrentFile()
})
ipc.on('openFiles', () => {
const dialog = require('./dialog')
dialog.openFiles()
})
/**
* Dock
*/
ipc.on('setBadge', (e, ...args) => {
const dock = require('./dock')
dock.setBadge(...args)
})
ipc.on('downloadFinished', (e, ...args) => {
const dock = require('./dock')
dock.downloadFinished(...args)
})
/**
* Events
*/
ipc.on('onPlayerOpen', function () {
const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = require('./shortcuts')
const thumbar = require('./thumbar')
menu.togglePlaybackControls(true)
powerSaveBlocker.enable()
shortcuts.enable()
thumbar.enable()
})
ipc.on('onPlayerUpdate', function (e, ...args) {
const thumbar = require('./thumbar')
menu.onPlayerUpdate(...args)
thumbar.onPlayerUpdate(...args)
})
ipc.on('onPlayerClose', function () {
const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = require('./shortcuts')
const thumbar = require('./thumbar')
menu.togglePlaybackControls(false)
powerSaveBlocker.disable()
shortcuts.disable()
thumbar.disable()
})
ipc.on('onPlayerPlay', function () {
const powerSaveBlocker = require('./power-save-blocker')
const thumbar = require('./thumbar')
powerSaveBlocker.enable()
thumbar.onPlayerPlay()
})
ipc.on('onPlayerPause', function () {
const powerSaveBlocker = require('./power-save-blocker')
const thumbar = require('./thumbar')
powerSaveBlocker.disable()
thumbar.onPlayerPause()
})
/**
* Shell
*/
ipc.on('openItem', (e, ...args) => {
const shell = require('./shell')
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
*/
ipc.on('setDefaultFileHandler', (e, flag) => {
const handlers = require('./handlers')
if (flag) handlers.install()
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
*/
const main = windows.main
ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
ipc.on('setProgress', (e, ...args) => main.setProgress(...args))
ipc.on('setTitle', (e, ...args) => main.setTitle(...args))
ipc.on('show', () => main.show())
ipc.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args))
ipc.on('setAllowNav', (e, ...args) => menu.setAllowNav(...args))
/**
* External Media Player
*/
ipc.on('checkForExternalPlayer', function (e, path) {
const externalPlayer = require('./external-player')
externalPlayer.checkInstall(path, function (isInstalled) {
windows.main.send('checkForExternalPlayer', isInstalled)
})
})
ipc.on('openExternalPlayer', (e, ...args) => {
const externalPlayer = require('./external-player')
const thumbar = require('./thumbar')
menu.togglePlaybackControls(false)
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) {
// Relay messages between the main window and the WebTorrent hidden window
if (name.startsWith('wt-') && !app.isQuitting) {
if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
// Send message to main window
windows.main.send(name, ...args)
log('webtorrent: got %s', name)
} else if (app.ipcReadyWebTorrent) {
// Send message to webtorrent window
windows.webtorrent.send(name, ...args)
log('webtorrent: sent %s', name)
} else {
// Queue message for webtorrent window, it hasn't finished loading yet
messageQueueMainToWebTorrent.push({
name: name,
args: args
})
log('webtorrent: queueing %s', name)
}
return
}
// Emit all other events normally
oldEmit.call(ipc, name, e, ...args)
}
}

View File

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

View File

@@ -1,49 +1,64 @@
module.exports = {
init,
onPlayerClose,
onPlayerOpen,
togglePlaybackControls,
setWindowFocus,
setAllowNav,
onPlayerUpdate,
onToggleAlwaysOnTop,
onToggleFullScreen,
onWindowBlur,
onWindowFocus
onToggleFullScreen
}
var electron = require('electron')
const electron = require('electron')
var app = electron.app
const app = electron.app
var config = require('../config')
var dialog = require('./dialog')
var shell = require('./shell')
var windows = require('./windows')
const config = require('../config')
const windows = require('./windows')
var menu
let menu = null
function init () {
menu = electron.Menu.buildFromTemplate(getMenuTemplate())
electron.Menu.setApplicationMenu(menu)
}
function onPlayerClose () {
getMenuItem('Play/Pause').enabled = false
getMenuItem('Increase Volume').enabled = false
getMenuItem('Decrease Volume').enabled = false
getMenuItem('Step Forward').enabled = false
getMenuItem('Step Backward').enabled = false
getMenuItem('Increase Speed').enabled = false
getMenuItem('Decrease Speed').enabled = false
getMenuItem('Add Subtitles File...').enabled = false
function togglePlaybackControls (flag) {
getMenuItem('Play/Pause').enabled = flag
getMenuItem('Skip Next').enabled = flag
getMenuItem('Skip Previous').enabled = flag
getMenuItem('Increase Volume').enabled = flag
getMenuItem('Decrease Volume').enabled = flag
getMenuItem('Step Forward').enabled = flag
getMenuItem('Step Backward').enabled = flag
getMenuItem('Increase Speed').enabled = flag
getMenuItem('Decrease Speed').enabled = flag
getMenuItem('Add Subtitles File...').enabled = flag
if (flag === false) {
getMenuItem('Skip Next').enabled = false
getMenuItem('Skip Previous').enabled = false
}
}
function onPlayerOpen () {
getMenuItem('Play/Pause').enabled = true
getMenuItem('Increase Volume').enabled = true
getMenuItem('Decrease Volume').enabled = true
getMenuItem('Step Forward').enabled = true
getMenuItem('Step Backward').enabled = true
getMenuItem('Increase Speed').enabled = true
getMenuItem('Decrease Speed').enabled = true
getMenuItem('Add Subtitles File...').enabled = true
function onPlayerUpdate (hasNext, hasPrevious) {
getMenuItem('Skip Next').enabled = hasNext
getMenuItem('Skip Previous').enabled = hasPrevious
}
function setWindowFocus (flag) {
getMenuItem('Full Screen').enabled = flag
getMenuItem('Float on Top').enabled = flag
}
// Disallow opening more screens on top of the current one.
function setAllowNav (flag) {
getMenuItem('Preferences').enabled = flag
if (process.platform === 'darwin') {
getMenuItem('Create New Torrent...').enabled = flag
} else {
getMenuItem('Create New Torrent from Folder...').enabled = flag
getMenuItem('Create New Torrent from File...').enabled = flag
}
}
function onToggleAlwaysOnTop (flag) {
@@ -54,19 +69,9 @@ function onToggleFullScreen (flag) {
getMenuItem('Full Screen').checked = flag
}
function onWindowBlur () {
getMenuItem('Full Screen').enabled = false
getMenuItem('Float on Top').enabled = false
}
function onWindowFocus () {
getMenuItem('Full Screen').enabled = true
getMenuItem('Float on Top').enabled = true
}
function getMenuItem (label) {
for (var i = 0; i < menu.items.length; i++) {
var menuItem = menu.items[i].submenu.items.find(function (item) {
for (let i = 0; i < menu.items.length; i++) {
const menuItem = menu.items[i].submenu.items.find(function (item) {
return item.label === label
})
if (menuItem) return menuItem
@@ -74,7 +79,7 @@ function getMenuItem (label) {
}
function getMenuTemplate () {
var template = [
const template = [
{
label: 'File',
submenu: [
@@ -83,26 +88,31 @@ function getMenuTemplate () {
? 'Create New Torrent...'
: 'Create New Torrent from Folder...',
accelerator: 'CmdOrCtrl+N',
click: () => dialog.openSeedDirectory()
click: () => {
const dialog = require('./dialog')
dialog.openSeedDirectory()
}
},
{
label: 'Open Torrent File...',
accelerator: 'CmdOrCtrl+O',
click: () => dialog.openTorrentFile()
click: () => {
const dialog = require('./dialog')
dialog.openTorrentFile()
}
},
{
label: 'Open Torrent Address...',
accelerator: 'CmdOrCtrl+U',
click: () => dialog.openTorrentAddress()
click: () => {
const dialog = require('./dialog')
dialog.openTorrentAddress()
}
},
{
type: 'separator'
},
{
label: process.platform === 'win32'
? 'Close'
: 'Close Window',
accelerator: 'CmdOrCtrl+W',
role: 'close'
}
]
@@ -111,32 +121,29 @@ function getMenuTemplate () {
label: 'Edit',
submenu: [
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut'
role: 'undo'
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy'
},
{
label: 'Paste Torrent Address',
accelerator: 'CmdOrCtrl+V',
role: 'paste'
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectall'
role: 'redo'
},
{
type: 'separator'
},
{
label: 'Preferences',
accelerator: 'CmdOrCtrl+,',
click: () => windows.main.dispatch('preferences')
role: 'cut'
},
{
role: 'copy'
},
{
label: 'Paste Torrent Address',
role: 'paste'
},
{
role: 'delete'
},
{
role: 'selectall'
}
]
},
@@ -200,6 +207,21 @@ function getMenuTemplate () {
{
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',
accelerator: 'CmdOrCtrl+Up',
@@ -262,30 +284,38 @@ function getMenuTemplate () {
submenu: [
{
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',
click: () => shell.openExternal(config.GITHUB_URL)
click: () => {
const shell = require('./shell')
shell.openExternal(config.GITHUB_URL)
}
},
{
type: 'separator'
},
{
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') {
// Add WebTorrent app menu (OS X)
// Add WebTorrent app menu (Mac)
template.unshift({
label: config.APP_NAME,
submenu: [
{
label: 'About ' + config.APP_NAME,
role: 'about'
},
{
@@ -300,7 +330,6 @@ function getMenuTemplate () {
type: 'separator'
},
{
label: 'Services',
role: 'services',
submenu: []
},
@@ -308,45 +337,34 @@ function getMenuTemplate () {
type: 'separator'
},
{
label: 'Hide ' + config.APP_NAME,
accelerator: 'Command+H',
role: 'hide'
},
{
label: 'Hide Others',
accelerator: 'Command+Alt+H',
role: 'hideothers'
},
{
label: 'Show All',
role: 'unhide'
},
{
type: 'separator'
},
{
label: 'Quit',
accelerator: 'Command+Q',
click: () => app.quit()
role: 'quit'
}
]
})
// Add Window menu (OS X)
// Add Window menu (Mac)
template.splice(5, 0, {
label: 'Window',
role: 'window',
submenu: [
{
label: 'Minimize',
accelerator: 'CmdOrCtrl+M',
role: 'minimize'
},
{
type: 'separator'
},
{
label: 'Bring All to Front',
role: 'front'
}
]
@@ -359,9 +377,23 @@ function getMenuTemplate () {
// File menu (Windows, Linux)
template[0].submenu.unshift({
label: 'Create New Torrent from File...',
click: () => dialog.openSeedFile()
click: () => {
const dialog = require('./dialog')
dialog.openSeedFile()
}
})
// Edit menu (Windows, Linux)
template[1].submenu.push(
{
type: 'separator'
},
{
label: 'Preferences',
accelerator: 'CmdOrCtrl+,',
click: () => windows.main.dispatch('preferences')
})
// Help menu (Windows, Linux)
template[4].submenu.push(
{

View File

@@ -3,17 +3,20 @@ module.exports = {
disable
}
var electron = require('electron')
var log = require('./log')
const electron = require('electron')
const log = require('./log')
var blockId = 0
let blockId = 0
/**
* Block the system from entering low-power (sleep) mode or turning off the
* display.
*/
function enable () {
disable() // Stop the previous power saver block, if one exists.
if (electron.powerSaveBlocker.isStarted(blockId)) {
// If a power saver block already exists, do nothing.
return
}
blockId = electron.powerSaveBlocker.start('prevent-display-sleep')
log(`powerSaveBlocker.enable: ${blockId}`)
}
@@ -23,6 +26,7 @@ function enable () {
*/
function disable () {
if (!electron.powerSaveBlocker.isStarted(blockId)) {
// If a power saver block does not exist, do nothing.
return
}
electron.powerSaveBlocker.stop(blockId)

View File

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

30
src/main/shortcuts.js Normal file
View File

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

View File

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

91
src/main/thumbar.js Normal file
View File

@@ -0,0 +1,91 @@
module.exports = {
disable,
enable,
onPlayerPause,
onPlayerPlay,
onPlayerUpdate
}
/**
* On Windows, add a "thumbnail toolbar" with a play/pause button in the taskbar.
* This provides users a way to access play/pause functionality without restoring
* or activating the window.
*/
const path = require('path')
const config = require('../config')
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.
*/
function enable () {
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.
*/
function disable () {
buttons = []
update()
}
function onPlayerPause () {
if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Play'
buttons[PLAY_PAUSE].icon = PLAY_ICON
update()
}
function onPlayerPlay () {
if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Pause'
buttons[PLAY_PAUSE].icon = PAUSE_ICON
update()
}
function onPlayerUpdate (state) {
if (!isEnabled()) return
buttons[PREV].flags = [ state.hasPrevious ? 'enabled' : 'disabled' ]
buttons[NEXT].flags = [ state.hasNext ? 'enabled' : 'disabled' ]
update()
}
function isEnabled () {
return buttons.length > 0
}
function update () {
windows.main.win.setThumbarButtons(buttons)
}

View File

@@ -1,18 +1,17 @@
module.exports = {
hasTray,
init,
onWindowBlur,
onWindowFocus
setWindowFocus
}
var electron = require('electron')
const electron = require('electron')
var app = electron.app
const app = electron.app
var config = require('../config')
var windows = require('./windows')
const config = require('../config')
const windows = require('./windows')
var tray
let tray
function init () {
if (process.platform === 'linux') {
@@ -21,7 +20,7 @@ function init () {
if (process.platform === 'win32') {
initWin32()
}
// OS X apps generally do not have menu bar icons
// Mac apps generally do not have menu bar icons
}
/**
@@ -31,12 +30,7 @@ function hasTray () {
return !!tray
}
function onWindowBlur () {
if (!tray) return
updateTrayMenu()
}
function onWindowFocus () {
function setWindowFocus (flag) {
if (!tray) return
updateTrayMenu()
}
@@ -55,7 +49,7 @@ function initWin32 () {
* Check for libappindicator1 support before creating tray icon
*/
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
// libappindicator1. If WebTorrent was installed from the deb file, we should
@@ -80,7 +74,7 @@ function createTray () {
}
function updateTrayMenu () {
var contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
const contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
tray.setContextMenu(contextMenu)
}

View File

@@ -2,16 +2,17 @@ module.exports = {
init
}
var electron = require('electron')
var get = require('simple-get')
const electron = require('electron')
const get = require('simple-get')
var config = require('../config')
var log = require('./log')
var windows = require('./windows')
const config = require('../config')
const log = require('./log')
const windows = require('./windows')
var AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
'?version=' + config.APP_VERSION +
'&platform=' + process.platform
'&platform=' + process.platform +
'&sysarch=' + config.OS_SYSARCH
function init () {
if (process.platform === 'linux') {
@@ -63,7 +64,7 @@ function initDarwinWin32 () {
electron.autoUpdater.on(
'update-not-available',
() => log('Update not available')
() => log('No update available')
)
electron.autoUpdater.on(

43
src/main/user-tasks.js Normal file
View File

@@ -0,0 +1,43 @@
module.exports = {
init
}
const electron = require('electron')
const app = electron.app
/**
* Add a user task menu to the app icon on right-click. (Windows)
*/
function init () {
if (process.platform !== 'win32') return
app.setUserTasks(getUserTasks())
}
function getUserTasks () {
return [
{
arguments: '-n',
title: 'Create New Torrent...',
description: 'Create a new torrent'
},
{
arguments: '-o',
title: 'Open Torrent File...',
description: 'Open a .torrent file'
},
{
arguments: '-u',
title: 'Open Torrent Address...',
description: 'Open a torrent from a URL'
}
].map(getUserTasksItem)
}
function getUserTasksItem (item) {
return Object.assign(item, {
program: process.execPath,
iconPath: process.execPath,
iconIndex: 0
})
}

View File

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

View File

@@ -1,4 +1,4 @@
var main = module.exports = {
const main = module.exports = {
dispatch,
hide,
init,
@@ -14,43 +14,58 @@ var main = module.exports = {
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')
var log = require('../log')
var menu = require('../menu')
var tray = require('../tray')
const config = require('../../config')
const log = require('../log')
const menu = require('../menu')
var HEADER_HEIGHT = 37
var TORRENT_HEIGHT = 100
function init () {
function init (state, options) {
if (main.win) {
return main.win.show()
}
var win = main.win = new electron.BrowserWindow({
backgroundColor: '#1E1E1E',
const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds)
const win = main.win = new electron.BrowserWindow({
backgroundColor: '#282828',
darkTheme: true, // Forces dark theme (GTK+3)
icon: getIconPath(), // Window icon (Windows, Linux)
minWidth: config.WINDOW_MIN_WIDTH,
minHeight: config.WINDOW_MIN_HEIGHT,
title: config.APP_WINDOW_TITLE,
titleBarStyle: 'hidden-inset', // Hide title bar (OS X)
titleBarStyle: 'hidden-inset', // Hide title bar (Mac)
useContentSize: true, // Specify web page size without OS chrome
width: 500,
height: HEADER_HEIGHT + (TORRENT_HEIGHT * 6) // header height + 5 torrents
show: false,
width: initialBounds.width,
height: initialBounds.height,
x: initialBounds.x,
y: initialBounds.y
})
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 () {
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('focus', onWindowFocus)
@@ -69,10 +84,23 @@ function init () {
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) {
if (process.platform !== 'darwin' && !tray.hasTray()) {
app.quit()
} else if (!app.isQuitting) {
if (process.platform !== 'darwin') {
const tray = require('../tray')
if (!tray.hasTray()) {
app.quit()
return
}
}
if (!app.isQuitting) {
e.preventDefault()
hide()
}
@@ -85,7 +113,7 @@ function dispatch (...args) {
function hide () {
if (!main.win) return
main.win.send('dispatch', 'backToList')
dispatch('backToList')
main.win.hide()
}
@@ -95,7 +123,7 @@ function send (...args) {
}
/**
* Enforce window aspect ratio. Remove with 0. (OS X)
* Enforce window aspect ratio. Remove with 0. (Mac)
*/
function setAspectRatio (aspectRatio) {
if (!main.win) return
@@ -114,7 +142,7 @@ function setBounds (bounds, maximize) {
}
// Maximize or minimize, if the second argument is present
var willBeMaximized
let willBeMaximized
if (maximize === true) {
if (!main.win.isMaximized()) {
log('setBounds: maximizing')
@@ -136,12 +164,18 @@ function setBounds (bounds, maximize) {
log('setBounds: setting bounds to ' + JSON.stringify(bounds))
if (bounds.x === null && bounds.y === null) {
// X and Y not specified? By default, center on current screen
var 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.y = Math.round(scr.bounds.y + scr.bounds.height / 2 - bounds.height / 2)
log('setBounds: centered to ' + JSON.stringify(bounds))
}
main.win.setBounds(bounds, true)
// Resize the window's content area (so window border doesn't need to be taken
// into account)
if (bounds.contentBounds) {
main.win.setContentBounds(bounds, true)
} else {
main.win.setBounds(bounds, true)
}
} else {
log('setBounds: not setting bounds because of window maximization')
}
@@ -196,7 +230,7 @@ function toggleFullScreen (flag) {
log(`toggleFullScreen ${flag}`)
if (flag) {
// Fullscreen and aspect ratio do not play well together. (OS X)
// Fullscreen and aspect ratio do not play well together. (Mac)
main.win.setAspectRatio(0)
}
@@ -204,13 +238,21 @@ function toggleFullScreen (flag) {
}
function onWindowBlur () {
menu.onWindowBlur()
tray.onWindowBlur()
menu.setWindowFocus(false)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(false)
}
}
function onWindowFocus () {
menu.onWindowFocus()
tray.onWindowFocus()
menu.setWindowFocus(true)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(true)
}
}
function getIconPath () {

View File

@@ -1,4 +1,4 @@
var webtorrent = module.exports = {
const webtorrent = module.exports = {
init,
send,
show,
@@ -6,13 +6,12 @@ var webtorrent = module.exports = {
win: null
}
var electron = require('electron')
const electron = require('electron')
var config = require('../../config')
var log = require('../log')
const config = require('../../config')
function init () {
var win = webtorrent.win = new electron.BrowserWindow({
const win = webtorrent.win = new electron.BrowserWindow({
backgroundColor: '#1E1E1E',
center: true,
fullscreen: false,
@@ -52,7 +51,6 @@ function send (...args) {
function toggleDevTools () {
if (!webtorrent.win) return
log('toggleDevTools')
if (webtorrent.win.webContents.isDevToolsOpened()) {
webtorrent.win.webContents.closeDevTools()
webtorrent.win.hide()

View File

@@ -0,0 +1,26 @@
const React = require('react')
const {dispatcher} = require('../lib/dispatcher')
module.exports = class CreateTorrentErrorPage extends React.Component {
render () {
return (
<div className='create-torrent'>
<h2>Create torrent</h2>
<p className='torrent-info'>
<p>
Sorry, you must select at least one file that is not a hidden file.
</p>
<p>
Hidden files, starting with a . character, are not included.
</p>
</p>
<p className='float-right'>
<button className='button-flat light' onClick={dispatcher('cancel')}>
Cancel
</button>
</p>
</div>
)
}
}

View File

@@ -0,0 +1,55 @@
const React = require('react')
const {dispatcher} = require('../lib/dispatcher')
class Header extends React.Component {
render () {
const loc = this.props.state.location
return (
<div className='header'
onMouseMove={dispatcher('mediaMouseMoved')}
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
{this.getTitle()}
<div className='nav left float-left'>
<i
className={'icon back ' + (loc.hasBack() ? '' : 'disabled')}
title='Back'
onClick={dispatcher('back')}>
chevron_left
</i>
<i
className={'icon forward ' + (loc.hasForward() ? '' : 'disabled')}
title='Forward'
onClick={dispatcher('forward')}>
chevron_right
</i>
</div>
<div className='nav right float-right'>
{this.getAddButton()}
</div>
</div>
)
}
getTitle () {
if (process.platform !== 'darwin') return null
const state = this.props.state
return (<div className='title ellipsis'>{state.window.title}</div>)
}
getAddButton () {
const state = this.props.state
if (state.location.url() !== 'home') return null
return (
<i
className='icon add'
title='Add torrent'
onClick={dispatcher('openFiles')}>
add
</i>
)
}
}
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,24 @@
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} />
</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

@@ -0,0 +1,30 @@
const React = require('react')
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch, dispatcher} = require('../lib/dispatcher')
module.exports = class RemoveTorrentModal extends React.Component {
render () {
const state = this.props.state
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?'
const buttonText = state.modal.deleteData ? 'REMOVE DATA' : 'REMOVE'
return (
<div>
<p><strong>{message}</strong></p>
<ModalOKCancel
cancelText='CANCEL'
onCancel={dispatcher('exitModal')}
okText={buttonText}
onOK={handleRemove} />
</div>
)
function handleRemove () {
dispatch('deleteTorrent', state.modal.infoHash, state.modal.deleteData)
dispatch('exitModal')
}
}
}

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

@@ -0,0 +1,35 @@
const React = require('react')
const electron = require('electron')
const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch} = require('../lib/dispatcher')
module.exports = class UpdateAvailableModal extends React.Component {
render () {
const state = this.props.state
return (
<div className='update-available-modal'>
<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>
<ModalOKCancel
cancelText='SKIP THIS RELEASE'
onCancel={handleSkip}
okText='SHOW DOWNLOAD PAGE'
onOK={handleShow} />
</div>
)
function handleShow () {
electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases')
dispatch('exitModal')
}
function handleSkip () {
dispatch('skipVersion', state.modal.version)
dispatch('exitModal')
}
}
}

View File

@@ -0,0 +1,79 @@
const electron = require('electron')
const ipcRenderer = electron.ipcRenderer
const Playlist = require('../lib/playlist')
// Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc)
module.exports = class MediaController {
constructor (state) {
this.state = state
}
mediaSuccess () {
this.state.playing.result = 'success'
}
mediaStalled () {
this.state.playing.isStalled = true
}
mediaError (error) {
const state = this.state
if (state.location.url() === 'player') {
state.playing.result = 'error'
state.playing.location = 'error'
ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath)
ipcRenderer.once('checkForExternalPlayer', function (e, isInstalled) {
state.modal = {
id: 'unsupported-media-modal',
error: error,
externalPlayerInstalled: isInstalled
}
})
}
}
mediaTimeUpdate () {
this.state.playing.lastTimeUpdate = new Date().getTime()
this.state.playing.isStalled = false
}
mediaMouseMoved () {
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 () {
const state = this.state
state.playing.location = 'external'
let open = function () {
const mediaURL = Playlist.getCurrentLocalURL(state)
ipcRenderer.send('openExternalPlayer',
state.saved.prefs.externalPlayerPath,
mediaURL,
state.window.title)
}
if (state.server != null) open()
else ipcRenderer.once('wt-server-running', open)
}
externalPlayerNotFound () {
const modal = this.state.modal
if (modal && modal.id === 'unsupported-media-modal') {
modal.externalPlayerNotFound = true
}
}
}

View File

@@ -0,0 +1,363 @@
const electron = require('electron')
const path = require('path')
const Cast = require('../lib/cast')
const {dispatch} = require('../lib/dispatcher')
const telemetry = require('../lib/telemetry')
const {UnplayableFileError, UnplayableTorrentError} = require('../lib/errors')
const sound = require('../lib/sound')
const TorrentPlayer = require('../lib/torrent-player')
const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const State = require('../lib/state')
const ipcRenderer = electron.ipcRenderer
// Controls playback of torrents and files within torrents
// both local (<video>,<audio>,external player) and remote (cast)
module.exports = class PlaybackController {
constructor (state, config, update) {
this.state = state
this.config = config
this.update = update
}
// Play a file in a torrent.
// * Start torrenting, if necessary
// * Stream, if not already fully downloaded
// * If no file index is provided, restore the most recently viewed file or autoplay the first
playFile (infoHash, index /* optional */) {
const state = this.state
if (state.location.url() === 'player') {
this.updatePlayer(infoHash, index, false, (err) => {
if (err) dispatch('error', err)
else this.play()
})
} 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)
})
}
}
// Open a file in OS default app.
openItem (infoHash, index) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const filePath = path.join(
torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
}
// Toggle (play or pause) the currently playing media
playPause () {
const state = this.state
if (state.location.url() !== 'player') return
// force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately
const mediaTag = document.querySelector('video,audio')
if (!state.window.isVisible && mediaTag) {
if (state.playing.isPaused) mediaTag.play()
else mediaTag.pause()
}
if (state.playing.isPaused) this.play()
else this.pause()
}
// 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 () {
const state = this.state
if (!state.playing.isPaused) return
state.playing.isPaused = false
if (isCasting(state)) {
Cast.play()
}
ipcRenderer.send('onPlayerPlay')
}
// Pause the currently playing media
pause () {
const state = this.state
if (state.playing.isPaused) return
state.playing.isPaused = true
if (isCasting(state)) {
Cast.pause()
}
ipcRenderer.send('onPlayerPause')
}
// Skip specified number of seconds (backwards if negative)
skip (time) {
this.skipTo(this.state.playing.currentTime + time)
}
// Skip (aka seek) to a specific point, in seconds
skipTo (time) {
if (!Number.isFinite(time)) {
console.error('Tried to skip to a non-finite time ' + time)
return console.trace()
}
if (isCasting(this.state)) Cast.seek(time)
else this.state.playing.jumpToTime = time
}
// Change playback speed. 1 = faster, -1 = slower
// Playback speed ranges from 16 (fast forward) to 1 (normal playback)
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
// until -16 (fast rewind)
changePlaybackRate (direction) {
const state = this.state
let rate = state.playing.playbackRate
if (direction > 0 && rate < 2) {
rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25
} else if (direction > 0 && rate >= 1 && rate < 16) {
rate *= 2
} else if (direction < 0 && rate > 1 && rate <= 16) {
rate /= 2
}
state.playing.playbackRate = rate
if (isCasting(state) && !Cast.setRate(rate)) {
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
// For example, volume muted (0), changeVolume (0.3) increases to 30% volume
changeVolume (delta) {
// change volume with delta value
this.setVolume(this.state.playing.volume + delta)
}
// Set the volume to some value in [0, 1]
setVolume (volume) {
// check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume))
const state = this.state
if (isCasting(state)) {
Cast.setVolume(volume)
} else {
state.playing.setVolume = volume
}
}
// 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
showOrHidePlayerControls () {
const state = this.state
const hideControls = state.shouldHidePlayerControls()
if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls
return true
}
return false
}
// Opens the video player to a specific torrent
openPlayer (infoHash, index, cb) {
const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
state.playing.infoHash = torrentSummary.infoHash
// update UI to show pending playback
sound.play('PLAY')
this.startServer(torrentSummary)
ipcRenderer.send('onPlayerOpen')
this.updatePlayer(infoHash, index, true, cb)
}
// Starts WebTorrent server for media streaming
startServer (torrentSummary) {
if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary.torrentKey)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => onTorrentReady())
} else {
onTorrentReady()
}
function onTorrentReady () {
ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
}
}
// Called each time the current file changes
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
state.playing.infoHash = infoHash
state.playing.fileIndex = index
state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video'
: TorrentPlayer.isAudio(fileSummary) ? 'audio'
: 'other'
// pick up where we left off
let jumpToTime = 0
if (resume && fileSummary.currentTime) {
const fraction = fileSummary.currentTime / fileSummary.duration
const secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
jumpToTime = fileSummary.currentTime
}
}
state.playing.jumpToTime = jumpToTime
// if it's audio, parse out the metadata (artist, title, etc)
if (torrentSummary.status === 'paused') {
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
dispatch('checkForSubtitles')
// enable previously selected subtitle track
if (fileSummary.selectedSubtitle) {
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
}
state.window.title = fileSummary.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()
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
cb()
}
closePlayer () {
console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC
const state = this.state
if (isCasting(state)) {
Cast.stop()
}
if (state.playing.location === 'external') {
ipcRenderer.send('quitExternalPlayer')
}
// Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume
// Telemetry: track what happens after the user clicks play
const 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 gave up waiting
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen
state.playing = State.getDefaultPlayState()
state.server = null
// Reset the window size and location back to where it was
if (state.window.isFullScreen) {
dispatch('toggleFullScreen', false)
}
restoreBounds(state)
// Tell the WebTorrent process to kill the torrent-to-HTTP server
ipcRenderer.send('wt-stop-server')
ipcRenderer.send('onPlayerClose')
this.update()
}
}
// Checks whether we are connected and already casting
// Returns false if we not casting (state.playing.location === 'local')
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
function isCasting (state) {
return state.playing.location === 'chromecast' ||
state.playing.location === 'airplay' ||
state.playing.location === 'dlna'
}
function restoreBounds (state) {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}

View File

@@ -0,0 +1,61 @@
const {dispatch} = require('../lib/dispatcher')
const ipcRenderer = require('electron').ipcRenderer
// Controls the Preferences screen
module.exports = class PrefsController {
constructor (state, config) {
this.state = state
this.config = config
}
// Goes to the Preferences screen
show () {
const state = this.state
state.location.go({
url: 'preferences',
setup: function (cb) {
// initialize preferences
state.window.title = 'Preferences'
state.unsaved = Object.assign(state.unsaved || {}, {
prefs: Object.assign({}, state.saved.prefs)
})
ipcRenderer.send('setAllowNav', false)
cb()
},
destroy: () => {
ipcRenderer.send('setAllowNav', true)
this.save()
}
})
}
// Updates a single property in the UNSAVED prefs
// For example: updatePreferences('foo.bar', 'baz')
// Call save() to save to config.json
update (property, value) {
const path = property.split('.')
let obj = this.state.unsaved.prefs
let i
for (i = 0; i < path.length - 1; i++) {
if (typeof obj[path[i]] === 'undefined') {
obj[path[i]] = {}
}
obj = obj[path[i]]
}
obj[path[i]] = value
}
// All unsaved prefs take effect atomically, and are saved to config.json
save () {
const state = this.state
if (state.unsaved.prefs.isFileHandler !== state.saved.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)
dispatch('stateSaveImmediate')
dispatch('checkDownloadPath')
}
}

View File

@@ -1,8 +1,10 @@
const electron = require('electron')
const fs = require('fs-extra')
const fs = require('fs')
const path = require('path')
const parallel = require('run-parallel')
const remote = electron.remote
const {dispatch} = require('../lib/dispatcher')
module.exports = class SubtitlesController {
@@ -11,7 +13,7 @@ module.exports = class SubtitlesController {
}
openSubtitles () {
electron.remote.dialog.showOpenDialog({
remote.dialog.showOpenDialog({
title: 'Select a subtitles file.',
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
properties: [ 'openFile' ]
@@ -26,36 +28,35 @@ module.exports = class SubtitlesController {
}
toggleSubtitlesMenu () {
var subtitles = this.state.playing.subtitles
const subtitles = this.state.playing.subtitles
subtitles.showMenu = !subtitles.showMenu
}
addSubtitles (files, autoSelect) {
var state = this.state
// 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
var subtitles = state.playing.subtitles
const subtitles = this.state.playing.subtitles
// 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) {
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
var track = tracks[i]
var trackIndex = state.playing.subtitles.tracks
.findIndex((t) => track.filePath === t.filePath)
const track = tracks[i]
let trackIndex = subtitles.tracks.findIndex((t) =>
track.filePath === t.filePath)
// Add the track
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 (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
state.playing.subtitles.selectedIndex = trackIndex
subtitles.selectedIndex = trackIndex
}
}
@@ -66,46 +67,46 @@ module.exports = class SubtitlesController {
checkForSubtitles () {
if (this.state.playing.type !== 'video') return
var torrentSummary = this.state.getPlayingTorrentSummary()
const torrentSummary = this.state.getPlayingTorrentSummary()
if (!torrentSummary || !torrentSummary.progress) return
torrentSummary.progress.files.forEach((fp, ix) => {
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
var filePath = path.join(torrentSummary.path, file.path)
const filePath = path.join(torrentSummary.path, file.path)
this.addSubtitles([filePath], false)
})
}
isSubtitle (file) {
var name = typeof file === 'string' ? file : file.name
var ext = path.extname(name).toLowerCase()
const name = typeof file === 'string' ? file : file.name
const ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt'
}
}
function loadSubtitle (file, cb) {
// Lazy load to keep startup fast
var concat = require('simple-concat')
var LanguageDetect = require('languagedetect')
var srtToVtt = require('srt-to-vtt')
const concat = require('simple-concat')
const LanguageDetect = require('languagedetect')
const srtToVtt = require('srt-to-vtt')
// 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) {
if (err) return dispatch('error', 'Can\'t parse subtitles file.')
// Detect what language the subtitles are in
var vttContents = buf.toString().replace(/(.*-->.*)/g, '')
var langDetected = (new LanguageDetect()).detect(vttContents, 2)
const vttContents = buf.toString().replace(/(.*-->.*)/g, '')
let langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
var track = {
const track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected,
label: langDetected,
@@ -116,21 +117,21 @@ function loadSubtitle (file, cb) {
})
}
// Checks whether a language name like "English" or "German" matches the system
// Checks whether a language name like 'English' or 'German' matches the system
// language, aka the current locale
function isSystemLanguage (language) {
var iso639 = require('iso-639-1')
var osLangISO = window.navigator.language.split('-')[0] // eg "en"
var langIso = iso639.getCode(language) // eg "de" if language is "German"
const iso639 = require('iso-639-1')
const osLangISO = window.navigator.language.split('-')[0] // eg 'en'
const langIso = iso639.getCode(language) // eg 'de' if language is 'German'
return langIso === osLangISO
}
// Make sure we don't have two subtitle tracks with the same label
// Labels each track by language, eg "German", "English", "English 2", ...
// Labels each track by language, eg 'German', 'English', 'English 2', ...
function relabelSubtitles (subtitles) {
var counts = {}
const counts = {}
subtitles.tracks.forEach(function (track) {
var lang = track.language
const lang = track.language
counts[lang] = (counts[lang] || 0) + 1
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 TorrentSummary = require('../lib/torrent-summary')
const TorrentPlayer = require('../lib/torrent-player')
const sound = require('../lib/sound')
const {dispatch} = require('../lib/dispatcher')
@@ -12,12 +11,12 @@ module.exports = class TorrentController {
}
torrentInfoHash (torrentKey, infoHash) {
var torrentSummary = this.getTorrentSummary(torrentKey)
let torrentSummary = this.getTorrentSummary(torrentKey)
console.log('got infohash for %s torrent %s',
torrentSummary ? 'existing' : 'new', torrentKey)
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
if (torrents.find((t) => t.infoHash === infoHash)) {
@@ -49,7 +48,7 @@ module.exports = class TorrentController {
}
dispatch('error', message)
var torrentSummary = this.getTorrentSummary(torrentKey)
const torrentSummary = this.getTorrentSummary(torrentKey)
if (torrentSummary) {
console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message)
torrentSummary.status = 'paused'
@@ -59,21 +58,20 @@ module.exports = class TorrentController {
torrentMetadata (torrentKey, torrentInfo) {
// Summarize torrent
var torrentSummary = this.getTorrentSummary(torrentKey)
const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.status = 'downloading'
torrentSummary.name = torrentSummary.displayName || torrentInfo.name
torrentSummary.path = torrentInfo.path
torrentSummary.magnetURI = torrentInfo.magnetURI
// TODO: make torrentInfo immutable, save separately as torrentSummary.info
// For now, check whether torrentSummary.files has already been set:
var hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
if (!hasDetailedFileInfo) {
torrentSummary.files = torrentInfo.files
}
if (!torrentSummary.selections) {
torrentSummary.selections = torrentSummary.files.map((x) => true)
}
torrentSummary.defaultPlayFileIndex = TorrentPlayer.pickFileToPlay(torrentInfo.files)
dispatch('update')
// Save the .torrent file, if it hasn't been saved already
@@ -85,7 +83,7 @@ module.exports = class TorrentController {
torrentDone (torrentKey, torrentInfo) {
// Update the torrent summary
var torrentSummary = this.getTorrentSummary(torrentKey)
const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.status = 'seeding'
// Notify the user that a torrent finished, but only if we actually DL'd at least part of it.
@@ -102,22 +100,18 @@ module.exports = class TorrentController {
}
torrentProgress (progressInfo) {
// Overall progress across all active torrents, 0 to 1
var progress = progressInfo.progress
var hasActiveTorrents = progressInfo.hasActiveTorrents
// Overall progress across all active torrents, 0 to 1, or -1 to hide the progress bar
// Hide progress bar when client has no torrents, or progress is 100%
// TODO: isn't this equivalent to: if (progress === 1) ?
if (!hasActiveTorrents || progress === 1) {
progress = -1
}
const progress = (!progressInfo.hasActiveTorrents || progressInfo.progress === 1)
? -1
: progressInfo.progress
// Show progress bar under the WebTorrent taskbar icon, on OSX
this.state.dock.progress = progress
// Update progress for each individual torrent
progressInfo.torrents.forEach((p) => {
var torrentSummary = this.getTorrentSummary(p.torrentKey)
const torrentSummary = this.getTorrentSummary(p.torrentKey)
if (!torrentSummary) {
console.log('warning: got progress for missing torrent %s', p.torrentKey)
return
@@ -129,32 +123,31 @@ module.exports = class TorrentController {
// files which are completed after a video starts to play to be added
// dynamically to the list of subtitles.
// checkForSubtitles()
dispatch('update')
}
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
dispatch('saveStateThrottled')
dispatch('stateSave')
}
torrentFileSaved (torrentKey, torrentFileName) {
console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
var torrentSummary = this.getTorrentSummary(torrentKey)
const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.torrentFileName = torrentFileName
dispatch('saveStateThrottled')
dispatch('stateSave')
}
torrentPosterSaved (torrentKey, posterFileName) {
var torrentSummary = this.getTorrentSummary(torrentKey)
const torrentSummary = this.getTorrentSummary(torrentKey)
torrentSummary.posterFileName = posterFileName
dispatch('saveStateThrottled')
dispatch('stateSave')
}
torrentAudioMetadata (infoHash, index, info) {
var torrentSummary = this.getTorrentSummary(infoHash)
var fileSummary = torrentSummary.files[index]
const torrentSummary = this.getTorrentSummary(infoHash)
const fileSummary = torrentSummary.files[index]
fileSummary.audioInfo = info
dispatch('update')
}
@@ -171,7 +164,7 @@ module.exports = class TorrentController {
}
function getTorrentPath (torrentSummary) {
var itemPath = TorrentSummary.getFileOrFolder(torrentSummary)
let itemPath = TorrentSummary.getFileOrFolder(torrentSummary)
if (torrentSummary.files.length > 1) {
itemPath = path.dirname(itemPath)
}
@@ -179,7 +172,7 @@ function getTorrentPath (torrentSummary) {
}
function showDoneNotification (torrent) {
var notif = new window.Notification('Download Complete', {
const notif = new window.Notification('Download Complete', {
body: torrent.name,
silent: true
})
@@ -188,5 +181,6 @@ function showDoneNotification (torrent) {
ipcRenderer.send('show')
}
sound.play('DONE')
// Only play notification sound if player is inactive
if (this.state.playing.isPaused) sound.play('DONE')
}

View File

@@ -0,0 +1,321 @@
const fs = require('fs')
const path = require('path')
const electron = require('electron')
const {dispatch} = require('../lib/dispatcher')
const {TorrentKeyNotFoundError} = require('../lib/errors')
const sound = require('../lib/sound')
const TorrentSummary = require('../lib/torrent-summary')
const ipcRenderer = electron.ipcRenderer
const instantIoRegex = /^(https:\/\/)?instant\.io\/#/
// Controls the torrent list: creating, adding, deleting, & manipulating torrents
module.exports = class TorrentListController {
constructor (state) {
this.state = state
}
// Adds a torrent to the list, starts downloading/seeding.
// TorrentID can be a magnet URI, infohash, or torrent file: https://git.io/vik9M
addTorrent (torrentId) {
if (torrentId.path) {
// Use path string instead of W3C File object
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
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
}
const torrentKey = this.state.nextTorrentKey++
const path = this.state.saved.prefs.downloadPath
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
dispatch('backToList')
}
// Shows the Create Torrent page with options to seed a given file or folder
showCreateTorrent (files) {
// You can only create torrents from the home screen.
if (this.state.location.url() !== 'home') {
return dispatch('error', 'Please go back to the torrent list before creating a new torrent.')
}
// Files will either be an array of file objects, which we can send directly
// to the create-torrent screen
if (files.length === 0 || typeof files[0] !== 'string') {
this.state.location.go({
url: 'create-torrent',
files: files,
setup: (cb) => {
this.state.window.title = 'Create New Torrent'
cb(null)
}
})
return
}
// ... or it will be an array of mixed file and folder paths. We have to walk
// through all the folders and find the files
findFilesRecursive(files, (allFiles) => this.showCreateTorrent(allFiles))
}
// Creates a new torrent and start seeeding
createTorrent (options) {
const state = this.state
const torrentKey = state.nextTorrentKey++
ipcRenderer.send('wt-create-torrent', torrentKey, options)
state.location.cancel()
}
// Starts downloading and/or seeding a given torrentSummary.
startTorrentingSummary (torrentKey) {
const s = TorrentSummary.getByKey(this.state, torrentKey)
if (!s) throw new TorrentKeyNotFoundError(torrentKey)
// New torrent: give it a path
if (!s.path) {
// Use Downloads folder by default
s.path = this.state.saved.prefs.downloadPath
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
fs.stat(fileOrFolder, function (err) {
if (err) {
s.error = 'path-missing'
dispatch('backToList')
return
}
start()
})
function start () {
ipcRenderer.send('wt-start-torrenting',
s.torrentKey,
TorrentSummary.getTorrentId(s),
s.path,
s.fileModtimes,
s.selections)
}
}
// TODO: use torrentKey, not infoHash
toggleTorrent (infoHash) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new'
this.startTorrentingSummary(torrentSummary.torrentKey)
sound.play('ENABLE')
} else {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
}
}
toggleTorrentFile (infoHash, index) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
torrentSummary.selections[index] = !torrentSummary.selections[index]
// Let the WebTorrent process know to start or stop fetching that file
if (torrentSummary.status !== 'paused') {
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
}
}
confirmDeleteTorrent (infoHash, deleteData) {
this.state.modal = {
id: 'remove-torrent-modal',
infoHash,
deleteData
}
}
// TODO: use torrentKey, not infoHash
deleteTorrent (infoHash, deleteData) {
ipcRenderer.send('wt-stop-torrenting', infoHash)
const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) {
const summary = this.state.saved.torrents[index]
// remove torrent and poster file
deleteFile(TorrentSummary.getTorrentPath(summary))
deleteFile(TorrentSummary.getPosterPath(summary))
// optionally delete the torrent data
if (deleteData) moveItemToTrash(summary)
// remove torrent from saved list
this.state.saved.torrents.splice(index, 1)
dispatch('stateSave')
}
// prevent user from going forward to a deleted torrent
this.state.location.clearForward('player')
sound.play('DELETE')
}
toggleSelectTorrent (infoHash) {
if (this.state.selectedInfoHash === infoHash) {
this.state.selectedInfoHash = null
} else {
this.state.selectedInfoHash = infoHash
}
}
openTorrentContextMenu (infoHash) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const torrentStarted = !!(torrentSummary.files && torrentSummary.progress)
const menuTemplate = [
{
label: 'Remove From List',
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, false)
},
{
label: 'Remove Data File',
enabled: torrentStarted,
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, true)
},
{
type: 'separator'
},
{
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder',
enabled: torrentStarted,
click: () => showItemInFolder(torrentSummary)
},
{
type: 'separator'
},
{
label: 'Copy Magnet Link to Clipboard',
click: () => electron.clipboard.writeText(torrentSummary.magnetURI)
},
{
label: 'Copy Instant.io Link to Clipboard',
click: () => electron.clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`)
},
{
label: 'Save Torrent File As...',
click: () => dispatch('saveTorrentFileAs', torrentSummary.torrentKey)
},
{
type: 'separator'
},
{
label: 'Paste Torrent Address',
role: 'paste'
}
]
const menu = electron.remote.Menu.buildFromTemplate(menuTemplate)
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
// Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (paths, cb) {
if (paths.length > 1) {
let numComplete = 0
let ret = []
paths.forEach(function (path) {
findFilesRecursive([path], function (fileObjs) {
ret.push(...fileObjs)
if (++numComplete === paths.length) {
ret.sort((a, b) => a.path < b.path ? -1 : a.path > b.path)
cb(ret)
}
})
})
return
}
const fileOrFolder = paths[0]
fs.stat(fileOrFolder, function (err, stat) {
if (err) return dispatch('error', err)
// Files: return name, path, and size
if (!stat.isDirectory()) {
const filePath = fileOrFolder
return cb([{
name: path.basename(filePath),
path: filePath,
size: stat.size
}])
}
// Folders: recurse, make a list of all the files
const folderPath = fileOrFolder
fs.readdir(folderPath, function (err, fileNames) {
if (err) return dispatch('error', err)
const paths = fileNames.map((fileName) => path.join(folderPath, fileName))
findFilesRecursive(paths, cb)
})
})
}
function deleteFile (path) {
if (!path) return
fs.unlink(path, function (err) {
if (err) dispatch('error', err)
})
}
// Delete all files in a torrent
function moveItemToTrash (torrentSummary) {
const filePath = TorrentSummary.getFileOrFolder(torrentSummary)
if (filePath) ipcRenderer.send('moveItemToTrash', filePath)
}
function showItemInFolder (torrentSummary) {
ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary))
}

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
module.exports = class UpdateController {
@@ -8,7 +8,7 @@ module.exports = class UpdateController {
// Shows a modal saying that we have an update
updateAvailable (version) {
var skipped = this.state.saved.skippedVersions
const skipped = this.state.saved.skippedVersions
if (skipped && skipped.includes(version)) {
console.log('new version skipped by user: v' + version)
return
@@ -18,9 +18,9 @@ module.exports = class UpdateController {
// Don't show the modal again until the next version
skipVersion (version) {
var skipped = this.state.saved.skippedVersions
let skipped = this.state.saved.skippedVersions
if (!skipped) skipped = this.state.saved.skippedVersions = []
skipped.push(version)
State.saveThrottled(this.state)
dispatch('stateSave')
}
}

View File

@@ -13,25 +13,35 @@ module.exports = {
setRate
}
// Lazy load these for a ~300ms improvement in startup time
var airplayer, chromecasts, dlnacasts
const config = require('../../config')
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
var state
let state
// Callback to notify module users when state has changed
var update
let update
// setInterval() for updating cast status
var statusInterval = null
let statusInterval = null
// Start looking for cast devices on the local network
function init (appState, callback) {
state = appState
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
airplayer = require('airplayer')()
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
function chromecastPlayer () {
var ret = {
const ret = {
device: null,
addDevice,
getDevices,
@@ -95,8 +131,8 @@ function chromecastPlayer () {
}
function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, {
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name
}, function (err) {
@@ -126,13 +162,7 @@ function chromecastPlayer () {
}
function status () {
ret.device.status(function (err, status) {
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()
})
ret.device.status(handleStatus)
}
function seek (time, callback) {
@@ -146,7 +176,7 @@ function chromecastPlayer () {
// airplay player implementation
function airplayPlayer () {
var ret = {
const ret = {
device: null,
addDevice,
getDevices,
@@ -183,7 +213,7 @@ function airplayPlayer () {
}
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) {
state.playing.location = 'local'
state.errors.push({
@@ -238,7 +268,7 @@ function airplayPlayer () {
// DLNA player implementation
function dlnaPlayer (player) {
var ret = {
const ret = {
device: null,
addDevice,
getDevices,
@@ -274,8 +304,8 @@ function dlnaPlayer (player) {
}
function open () {
var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL, {
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name,
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0
@@ -306,13 +336,7 @@ function dlnaPlayer (player) {
}
function status () {
ret.device.status(function (err, status) {
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()
})
ret.device.status(handleStatus)
}
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
function startStatusInterval () {
statusInterval = setInterval(function () {
var player = getPlayer()
const player = getPlayer()
if (player) player.status()
}, 1000)
}
@@ -350,23 +386,26 @@ function toggleMenu (location) {
// Never cast to two devices at the same time
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
var player = getPlayer(location)
var devices = player ? player.getDevices() : []
if (devices.length === 0) throw new Error('No ' + location + ' devices available')
const player = getPlayer(location)
const devices = player ? player.getDevices() : []
if (devices.length === 0) {
throw new CastingError(`No ${location} devices available`)
}
// Show a menu
state.devices.castMenu = {location, devices}
}
function selectDevice (index) {
var {location, devices} = state.devices.castMenu
const {location, devices} = state.devices.castMenu
// Start casting
var player = getPlayer(location)
const player = getPlayer(location)
player.device = devices[index]
player.open()
@@ -382,7 +421,7 @@ function selectDevice (index) {
// Stops casting, move video back to local screen
function stop () {
var player = getPlayer()
const player = getPlayer()
if (player) {
player.stop(function () {
player.device = null
@@ -396,7 +435,9 @@ function stop () {
function stoppedCasting () {
state.playing.location = 'local'
state.playing.jumpToTime = state.playing.currentTime
state.playing.jumpToTime = Number.isFinite(state.playing.currentTime)
? state.playing.currentTime
: 0
update()
}
@@ -415,18 +456,18 @@ function getPlayer (location) {
}
function play () {
var player = getPlayer()
const player = getPlayer()
if (player) player.play(castCallback)
}
function pause () {
var player = getPlayer()
const player = getPlayer()
if (player) player.pause(castCallback)
}
function setRate (rate) {
var player
var result = true
let player
let result = true
if (state.playing.location === 'chromecast') {
// TODO find how to control playback rate on chromecast
castCallback()
@@ -441,12 +482,12 @@ function setRate (rate) {
}
function seek (time) {
var player = getPlayer()
const player = getPlayer()
if (player) player.seek(time, castCallback)
}
function setVolume (volume) {
var player = getPlayer()
const player = getPlayer()
if (player) player.volume(volume, castCallback)
}

View File

@@ -4,8 +4,8 @@ module.exports = {
setDispatch
}
var dispatchers = {}
var _dispatch = function () {}
const dispatchers = {}
let _dispatch = function () {}
function setDispatch (dispatch) {
_dispatch = dispatch
@@ -17,11 +17,11 @@ function dispatch (...args) {
// Most DOM event handlers are trivial functions like `() => dispatch(<args>)`.
// For these, `dispatcher(<args>)` is preferred because it memoizes the handler
// function. This prevents virtual-dom from updating the listener functions on
// function. This prevents React from updating the listener functions on
// each update().
function dispatcher (...args) {
var str = JSON.stringify(args)
var handler = dispatchers[str]
const str = JSON.stringify(args)
let handler = dispatchers[str]
if (!handler) {
handler = dispatchers[str] = function (e) {
// Do not propagate click to elements below the button

View File

@@ -0,0 +1,41 @@
const ExtendableError = require('es6-error')
/* 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') }
}
class UnplayableFileError extends PlaybackError {
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
}

View File

@@ -0,0 +1,208 @@
/* eslint-disable camelcase */
module.exports = {
run
}
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
// needed, for example to deal with config.json format changes across versions
function run (state) {
// Replace '{ version: 1 }' with app version (semver)
if (!semver.valid(state.saved.version)) {
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
}
const version = state.saved.version
const saved = state.saved
if (semver.lt(version, '0.7.0')) migrate_0_7_0(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.14.0')) migrate_0_14_0(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)
// Config is now on the new version
state.saved.version = config.APP_VERSION
}
function migrate_0_7_0 (saved) {
const cpFile = require('cp-file')
const path = require('path')
saved.torrents.forEach(function (ts) {
const infoHash = ts.infoHash
// Replace torrentPath with torrentFileName
// There are a number of cases to handle here:
// * Originally we used absolute paths
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
// * Finally, now we're getting rid of torrentPath altogether
let src, dst
if (ts.torrentPath) {
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
src = ts.torrentPath
} else {
src = path.join(config.STATIC_PATH, ts.torrentPath)
}
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) cpFile.sync(src, dst)
delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent'
}
// Replace posterURL with posterFileName
if (ts.posterURL) {
const extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL)
? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once
if (src !== dst) cpFile.sync(src, dst)
delete ts.posterURL
ts.posterFileName = infoHash + extension
}
// Fix exception caused by incorrect file ordering.
// https://github.com/feross/webtorrent-desktop/pull/604#issuecomment-222805214
delete ts.defaultPlayFileIndex
delete ts.files
delete ts.selections
delete ts.fileModtimes
})
}
function migrate_0_7_2 (saved) {
if (saved.prefs == null) {
saved.prefs = {
downloadPath: config.DEFAULT_DOWNLOAD_PATH
}
}
}
function migrate_0_11_0 (saved) {
if (saved.prefs.isFileHandler == null) {
// The app used to make itself the default torrent file handler automatically
saved.prefs.isFileHandler = true
}
}
function migrate_0_12_0 (saved) {
const TorrentSummary = require('./torrent-summary')
if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) {
saved.prefs.openExternalPlayer = saved.prefs.playInVlc
}
delete saved.prefs.playInVlc
// Undo a terrible bug where clicking Play on a default torrent on a fresh
// install results in a "path missing" error
// See https://github.com/feross/webtorrent-desktop/pull/806
const defaultTorrentFiles = [
'6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent',
'88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent',
'6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent',
'02767050e0be2fd4db9a2ad6c12416ac806ed6ed.torrent',
'3ba219a8634bf7bae3d848192b2da75ae995589d.torrent'
]
saved.torrents.forEach(function (torrentSummary) {
if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return
const fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary)
if (!fileOrFolder) return
try {
fs.statSync(fileOrFolder)
} catch (err) {
// Default torrent with "missing path" error. Clear 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/feross/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 = {
preload,
play
}
var config = require('../../config')
var path = require('path')
const config = require('../../config')
const {InvalidSoundNameError} = require('./errors')
const path = require('path')
var VOLUME = 0.15
const VOLUME = 0.25
/* Cache of Audio elements, for instant playback */
var cache = {}
const cache = {}
var sounds = {
const sounds = {
ADD: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
volume: VOLUME
},
DELETE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
volume: VOLUME
volume: VOLUME * 0.5
},
DISABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
volume: VOLUME
volume: VOLUME * 0.5
},
DONE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
@@ -30,7 +30,7 @@ var sounds = {
},
ENABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
volume: VOLUME
volume: VOLUME * 0.5
},
ERROR: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
@@ -42,27 +42,16 @@ var sounds = {
},
STARTUP: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
volume: VOLUME * 2
}
}
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
}
volume: VOLUME
}
}
function play (name) {
var audio = cache[name]
let audio = cache[name]
if (!audio) {
var sound = sounds[name]
const sound = sounds[name]
if (!sound) {
throw new Error('Invalid sound name')
throw new InvalidSoundNameError(name)
}
audio = cache[name] = new window.Audio()
audio.volume = sound.volume

View File

@@ -1,21 +1,30 @@
var appConfig = require('application-config')('WebTorrent')
var path = require('path')
var {EventEmitter} = require('events')
const appConfig = require('application-config')('WebTorrent')
const path = require('path')
const {EventEmitter} = require('events')
var config = require('../../config')
var migrations = require('./migrations')
const config = require('../../config')
var State = module.exports = Object.assign(new EventEmitter(), {
getDefaultPlayState,
load,
save,
saveThrottled
})
const SAVE_DEBOUNCE_INTERVAL = 1000
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 () {
var LocationHistory = require('./location-history')
const LocationHistory = require('location-history')
return {
/*
@@ -24,7 +33,11 @@ function getDefaultState () {
*/
client: null, /* the WebTorrent client */
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(),
window: {
bounds: null, /* {x, y, width, height } */
@@ -50,7 +63,7 @@ function getDefaultState () {
*
* Config path:
*
* OS X ~/Library/Application Support/WebTorrent/config.json
* Mac ~/Library/Application Support/WebTorrent/config.json
* Linux (XDG) $XDG_CONFIG_HOME/WebTorrent/config.json
* Linux (Legacy) ~/.config/WebTorrent/config.json
* Windows (> Vista) %LOCALAPPDATA%/WebTorrent/config.json
@@ -64,7 +77,9 @@ function getDefaultState () {
* Getters, for convenience
*/
getPlayingTorrentSummary,
getPlayingFileSummary
getPlayingFileSummary,
getExternalPlayerName,
shouldHidePlayerControls
}
}
@@ -82,6 +97,7 @@ function getDefaultPlayState () {
lastTimeUpdate: 0, /* Unix time in ms */
mouseStationarySince: 0, /* Unix time in ms */
playbackRate: 1,
volume: 1,
subtitles: {
tracks: [], /* subtitle tracks, each {label, language, ...} */
selectedIndex: -1, /* current subtitle track */
@@ -92,36 +108,39 @@ function getDefaultPlayState () {
}
/* If the saved state file doesn't exist yet, here's what we use instead */
function setupSavedState (cb) {
var fs = require('fs-extra')
var parseTorrent = require('parse-torrent')
var parallel = require('run-parallel')
function setupStateSaved (cb) {
const cpFile = require('cp-file')
const fs = require('fs')
const parseTorrent = require('parse-torrent')
const parallel = require('run-parallel')
var saved = {
const saved = {
prefs: {
downloadPath: config.DEFAULT_DOWNLOAD_PATH
downloadPath: config.DEFAULT_DOWNLOAD_PATH,
isFileHandler: false,
openExternalPlayer: false,
externalPlayerPath: null,
startup: false
},
torrents: config.DEFAULT_TORRENTS.map(createTorrentObject),
version: config.APP_VERSION /* make sure we can upgrade gracefully later */
}
var tasks = []
const tasks = []
config.DEFAULT_TORRENTS.map(function (t, i) {
var infoHash = saved.torrents[i].infoHash
const infoHash = saved.torrents[i].infoHash
tasks.push(function (cb) {
fs.copy(
cpFile(
path.join(config.STATIC_PATH, t.posterFileName),
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)),
cb
)
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName))
).then(cb).catch(cb)
})
tasks.push(function (cb) {
fs.copy(
cpFile(
path.join(config.STATIC_PATH, t.torrentFileName),
path.join(config.TORRENT_PATH, infoHash + '.torrent'),
cb
)
path.join(config.TORRENT_PATH, infoHash + '.torrent')
).then(cb).catch(cb)
})
})
@@ -131,8 +150,9 @@ function setupSavedState (cb) {
})
function createTorrentObject (t) {
var torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
var parsedTorrent = parseTorrent(torrent)
// TODO: Doing several fs.readFileSync calls during first startup is not ideal
const torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
const parsedTorrent = parseTorrent(torrent)
return {
status: 'paused',
@@ -143,61 +163,82 @@ function setupSavedState (cb) {
torrentFileName: parsedTorrent.infoHash + '.torrent',
magnetURI: parseTorrent.toMagnetURI(parsedTorrent),
files: parsedTorrent.files,
selections: parsedTorrent.files.map((x) => true)
selections: parsedTorrent.files.map((x) => true),
testID: t.testID
}
}
}
function getPlayingTorrentSummary () {
var infoHash = this.playing.infoHash
const infoHash = this.playing.infoHash
return this.saved.torrents.find((x) => x.infoHash === infoHash)
}
function getPlayingFileSummary () {
var torrentSummary = this.getPlayingTorrentSummary()
const torrentSummary = this.getPlayingTorrentSummary()
if (!torrentSummary) return null
return torrentSummary.files[this.playing.fileIndex]
}
function load (cb) {
var state = getDefaultState()
function getExternalPlayerName () {
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) {
if (err || !saved.version) {
console.log('Missing config file: Creating new one')
setupSavedState(onSaved)
setupStateSaved(onSavedState)
} else {
onSaved(null, saved)
onSavedState(null, saved)
}
})
function onSaved (err, saved) {
function onSavedState (err, saved) {
if (err) return cb(err)
const state = getDefaultState()
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)
}
}
// Write state.saved to the JSON state file
function save (state, cb) {
function saveImmediate (state, cb) {
console.log('Saving state to ' + appConfig.filePath)
delete state.saveStateTimeout
// 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
// reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents
.filter((x) => x.infoHash)
.map(function (x) {
var torrent = {}
for (var key in x) {
const torrent = {}
for (let key in x) {
if (key === 'progress' || key === 'torrentKey') {
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') {
continue // Don't save error states
}
torrent[key] = x[key]
}
@@ -206,15 +247,6 @@ function save (state, cb) {
appConfig.write(copy, (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

@@ -0,0 +1,223 @@
// Collects anonymous usage stats and uncaught errors
// Reports back so that we can improve WebTorrent Desktop
module.exports = {
init,
send,
logUncaughtError,
logPlayAttempt
}
const electron = require('electron')
const config = require('../../config')
let telemetry
function init (state) {
telemetry = state.saved.telemetry
// First app run
if (!telemetry) {
const crypto = require('crypto')
telemetry = state.saved.telemetry = {
userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID
}
reset()
}
}
function send (state) {
const now = new Date()
telemetry.version = config.APP_VERSION
telemetry.timestamp = now.toISOString()
telemetry.localTime = now.toTimeString()
telemetry.screens = getScreenInfo()
telemetry.system = getSystemInfo()
telemetry.torrentStats = getTorrentStats(state)
telemetry.approxNumTorrents = telemetry.torrentStats.approxCount
if (!config.IS_PRODUCTION) {
// Development: telemetry used only for local debugging
// Empty uncaught errors, etc at the start of every run
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 () {
telemetry.uncaughtErrors = []
telemetry.playAttempts = {
minVersion: config.APP_VERSION,
total: 0,
success: 0,
timeout: 0,
error: 0,
abandoned: 0
}
}
// Track screen resolution
function getScreenInfo () {
return electron.screen.getAllDisplays().map((screen) => ({
width: screen.size.width,
height: screen.size.height,
scaleFactor: screen.scaleFactor
}))
}
// Track basic system info like OS version and amount of RAM
function getSystemInfo () {
const os = require('os')
return {
osPlatform: process.platform,
osRelease: os.type() + ' ' + os.release(),
architecture: os.arch(),
systemArchitecture: config.OS_SYSARCH,
totalMemoryMB: roundPow2(os.totalmem() / (1 << 20)),
numCores: os.cpus().length
}
}
// Get stats like the # of torrents currently active, # in list, total size
function getTorrentStats (state) {
const count = state.saved.torrents.length
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
const log2 = Math.log(n) / Math.log(2)
return Math.pow(2, Math.round(log2))
}
// An uncaught error happened in the main process or in one of the windows
function logUncaughtError (procName, e) {
// Not initialized yet? Ignore.
// Hopefully uncaught errors immediately on startup are fixed in dev
if (!telemetry) return
let message
let stack = ''
if (e == null) {
message = 'Unexpected undefined error'
} else if (e.error) {
// Uncaught Javascript errors (window.onerror), err is an ErrorEvent
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 {
// Resource errors (captured element.onerror), err is an Event
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
if (telemetry.uncaughtErrors.length > 20) return
if (message.length > 1000) message = message.substring(0, 1000)
if (stack.length > 1000) stack = stack.substring(0, 1000)
// Log the app version *at the time of the error*
const version = config.APP_VERSION
telemetry.uncaughtErrors.push({process: procName, message, stack, version})
}
// Turns a DOM element into a string, eg "DIV.my-class.visible"
function getElemString (elem) {
let ret = elem.tagName
try {
ret += '.' + Array.from(elem.classList).join('.')
} catch (err) {}
return ret
}
// The user pressed play. It either worked, timed out, or showed the
// 'Play in VLC' codec error
function logPlayAttempt (result) {
if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) {
return console.error('Unknown play attempt result', result)
}
const attempts = telemetry.playAttempts
attempts.total = (attempts.total || 0) + 1
attempts[result] = (attempts[result] || 0) + 1
}

View File

@@ -3,11 +3,10 @@ module.exports = {
isVideo,
isAudio,
isTorrent,
isPlayableTorrentSummary,
pickFileToPlay
isPlayableTorrentSummary
}
var path = require('path')
const path = require('path')
// Checks whether a fileSummary or file path is audio/video that we can play,
// based on the file extension
@@ -37,7 +36,8 @@ function isAudio (file) {
'.ac3',
'.mp3',
'.ogg',
'.wav'
'.wav',
'.m4a'
].includes(getFileExtension(file))
}
@@ -46,38 +46,16 @@ function isAudio (file) {
// - a file object where obj.name is ends in .torrent
// - a string that's a magnet link (magnet://...)
function isTorrent (file) {
var isTorrentFile = getFileExtension(file) === '.torrent'
var isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file)
const isTorrentFile = getFileExtension(file) === '.torrent'
const isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file)
return isTorrentFile || isMagnet
}
function getFileExtension (file) {
var name = typeof file === 'string' ? file : file.name
const name = typeof file === 'string' ? file : file.name
return path.extname(name).toLowerCase()
}
function isPlayableTorrentSummary (torrentSummary) {
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
var captureVideoFrame = require('./capture-video-frame')
var path = require('path')
const captureFrame = require('capture-frame')
const path = require('path')
function torrentPoster (torrent, cb) {
// 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)
})[0]
if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb)
// Second, try to use the largest video file
// 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)
// 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)
// TODO: generate a waveform from the largest sound file
@@ -25,8 +25,8 @@ function torrentPoster (torrent, cb) {
}
function getLargestFileByExtension (torrent, extensions) {
var files = torrent.files.filter(function (file) {
var extname = path.extname(file.name).toLowerCase()
const files = torrent.files.filter(function (file) {
const extname = path.extname(file.name).toLowerCase()
return extensions.indexOf(extname) !== -1
})
if (files.length === 0) return undefined
@@ -36,15 +36,15 @@ function getLargestFileByExtension (torrent, extensions) {
}
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)
function onListening () {
var port = server.address().port
var url = 'http://localhost:' + port + '/' + index
var video = document.createElement('video')
const port = server.address().port
const url = 'http://localhost:' + port + '/' + index
const video = document.createElement('video')
video.addEventListener('canplay', onCanPlay)
video.volume = 0
@@ -61,7 +61,7 @@ function torrentPosterFromVideo (file, torrent, cb) {
function onSeeked () {
video.removeEventListener('seeked', onSeeked)
var buf = captureVideoFrame(video)
const buf = captureFrame(video)
// unload video element
video.pause()
@@ -78,6 +78,6 @@ function torrentPosterFromVideo (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))
}

View File

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

526
src/renderer/main.js Normal file
View File

@@ -0,0 +1,526 @@
/**
* Perf optimization: Hook into require() to modify how certain modules load:
*
* - `lodash/merge` (used by `material-ui`) causes 119 require() calls at startup,
* which take ~100ms. Replace it with `lodash.merge` which is equivalent.
* See: https://github.com/callemall/material-ui/pull/4380#issuecomment-250894552
*
* - `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 === 'lodash/merge') id = 'lodash.merge'
if (id === 'inline-style-prefixer') return {}
return _require.apply(this, arguments)
}
console.time('init')
const crashReporter = require('../crash-reporter')
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 dragDrop = require('drag-drop')
const electron = require('electron')
const fs = require('fs')
const React = require('react')
const ReactDOM = require('react-dom')
const config = require('../config')
const telemetry = require('./lib/telemetry')
const sound = require('./lib/sound')
const TorrentPlayer = require('./lib/torrent-player')
// Perf optimization: Needed immediately, so do not lazy load it below
const TorrentListController = require('./controllers/torrent-list-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.
// Events come back up from the views via dispatch(...)
require('./lib/dispatcher').setDispatch(dispatch)
// From dispatch(...), events are sent to one of the controllers
let controllers = null
// This dependency is the slowest-loading, so we lazy load it
let Cast = null
// 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.
let state
// Root React component
let app
// Called once when the application loads. (Not once per window.)
// Connects to the torrent networks, sets up the UI and OS integrations like
// the dock icon and drag+drop.
function onState (err, _state) {
if (err) return onError(err)
// 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
controllers = {
media: createGetter(() => {
const MediaController = require('./controllers/media-controller')
return new MediaController(state)
}),
playback: createGetter(() => {
const PlaybackController = require('./controllers/playback-controller')
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)
})
}
// Add first page to location history
state.location.go({
url: 'home',
setup: (cb) => {
state.window.title = config.APP_WINDOW_TITLE
cb(null)
}
})
// Restart everything we were torrenting last time the app ran
resumeTorrents()
// Initialize ReactDOM
app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
// Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary
// a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000)
// Listen for messages from the main process
setupIpc()
// Drag and drop files/text to start torrenting or seeding
dragDrop('body', {
onDrop: onOpen,
onDropText: onOpen
})
// ...same thing if you paste a torrent
document.addEventListener('paste', onPaste)
// ...focus and blur. Needed to show correct dock icon text ('badge') in OSX
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
if (electron.remote.getCurrentWindow().isVisible()) {
sound.play('STARTUP')
}
// To keep app startup fast, some code is delayed.
window.setTimeout(delayedInit, config.DELAYED_INIT)
// Done! Ideally we want to get here < 500ms after the user clicks the app
console.timeEnd('init')
}
// Runs a few seconds after the app loads, to avoid slowing down startup time
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()
}
// Lazily loads Chromecast and Airplay support
function lazyLoadCast () {
if (!Cast) {
Cast = require('./lib/cast')
Cast.init(state, update) // Search the local network for Chromecast and Airplays
}
return Cast
}
// React loop:
// 1. update() - recompute the virtual DOM, diff, apply to the real DOM
// 2. event - might be a click or other DOM event, or something external
// 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller
// 4. controller - the controller handles the event, changing the state object
function update () {
controllers.playback().showOrHidePlayerControls()
app.setState(state)
updateElectron()
}
// Some state changes can't be reflected in the DOM, instead we have to
// tell the main process to update the window or OS integrations
function updateElectron () {
if (state.window.title !== state.prev.title) {
state.prev.title = state.window.title
ipcRenderer.send('setTitle', state.window.title)
}
if (state.dock.progress.toFixed(2) !== state.prev.progress.toFixed(2)) {
state.prev.progress = state.dock.progress
ipcRenderer.send('setProgress', state.dock.progress)
}
if (state.dock.badge !== state.prev.badge) {
state.prev.badge = state.dock.badge
ipcRenderer.send('setBadge', state.dock.badge || 0)
}
}
const dispatchHandlers = {
// Torrent list: creating, deleting, selecting torrents
'openTorrentFile': () => ipcRenderer.send('openTorrentFile'),
'openFiles': () => ipcRenderer.send('openFiles'), /* shows the open file dialog */
'openTorrentAddress': () => { state.modal = { id: 'open-torrent-address-modal' } },
'addTorrent': (torrentId) => controllers.torrentList().addTorrent(torrentId),
'showCreateTorrent': (paths) => controllers.torrentList().showCreateTorrent(paths),
'createTorrent': (options) => controllers.torrentList().createTorrent(options),
'toggleTorrent': (infoHash) => controllers.torrentList().toggleTorrent(infoHash),
'toggleTorrentFile': (infoHash, index) =>
controllers.torrentList().toggleTorrentFile(infoHash, index),
'confirmDeleteTorrent': (infoHash, deleteData) =>
controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData),
'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),
// Playback
'playFile': (infoHash, index) => controllers.playback().playFile(infoHash, index),
'playPause': () => controllers.playback().playPause(),
'nextTrack': () => controllers.playback().nextTrack(),
'previousTrack': () => controllers.playback().previousTrack(),
'skip': (time) => controllers.playback().skip(time),
'skipTo': (time) => controllers.playback().skipTo(time),
'changePlaybackRate': (dir) => controllers.playback().changePlaybackRate(dir),
'changeVolume': (delta) => controllers.playback().changeVolume(delta),
'setVolume': (vol) => controllers.playback().setVolume(vol),
'openItem': (infoHash, index) => controllers.playback().openItem(infoHash, index),
// Subtitles
'openSubtitles': () => controllers.subtitles().openSubtitles(),
'selectSubtitle': (index) => controllers.subtitles().selectSubtitle(index),
'toggleSubtitlesMenu': () => controllers.subtitles().toggleSubtitlesMenu(),
'checkForSubtitles': () => controllers.subtitles().checkForSubtitles(),
'addSubtitles': (files, autoSelect) => controllers.subtitles().addSubtitles(files, autoSelect),
// Local media: <video>, <audio>, external players
'mediaStalled': () => controllers.media().mediaStalled(),
'mediaError': (err) => controllers.media().mediaError(err),
'mediaSuccess': () => controllers.media().mediaSuccess(),
'mediaTimeUpdate': () => controllers.media().mediaTimeUpdate(),
'mediaMouseMoved': () => controllers.media().mediaMouseMoved(),
'mediaControlsMouseEnter': () => controllers.media().controlsMouseEnter(),
'mediaControlsMouseLeave': () => controllers.media().controlsMouseLeave(),
'openExternalPlayer': () => controllers.media().openExternalPlayer(),
'externalPlayerNotFound': () => controllers.media().externalPlayerNotFound(),
// Remote casting: Chromecast, Airplay, etc
'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType),
'selectCastDevice': (index) => lazyLoadCast().selectDevice(index),
'stopCasting': () => lazyLoadCast().stop(),
// Preferences screen
'preferences': () => controllers.prefs().show(),
'updatePreferences': (key, value) => controllers.prefs().update(key, value),
'checkDownloadPath': checkDownloadPath,
// Update (check for new versions on Linux, where there's no auto updater)
'updateAvailable': (version) => controllers.update().updateAvailable(version),
'skipVersion': (version) => controllers.update().skipVersion(version),
// Navigation between screens (back, forward, ESC, etc)
'exitModal': () => { state.modal = null },
'backToList': backToList,
'escapeBack': escapeBack,
'back': () => state.location.back(),
'forward': () => state.location.forward(),
'cancel': () => state.location.cancel(),
// Controlling the window
'setDimensions': setDimensions,
'toggleFullScreen': (setTo) => ipcRenderer.send('toggleFullScreen', setTo),
'setTitle': (title) => { state.window.title = title },
'resetTitle': () => { state.window.title = config.APP_WINDOW_TITLE },
// Everything else
'onOpen': onOpen,
'error': onError,
'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err),
'stateSave': () => State.save(state),
'stateSaveImmediate': () => State.saveImmediate(state),
'update': () => {} // No-op, just trigger an update
}
// Events from the UI never modify state directly. Instead they call dispatch()
function dispatch (action, ...args) {
// Log dispatch calls, for debugging, but don't spam
if (!['mediaMouseMoved', 'mediaTimeUpdate', 'update'].includes(action)) {
console.log('dispatch: %s %o', action, args)
}
const handler = dispatchHandlers[action]
if (handler) handler(...args)
else console.error('Missing dispatch handler: ' + action)
// Update the virtual DOM, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' ||
controllers.playback().showOrHidePlayerControls()) {
update()
}
}
// Listen to events from the main and webtorrent processes
function setupIpc () {
ipcRenderer.on('log', (e, ...args) => console.log(...args))
ipcRenderer.on('error', (e, ...args) => console.error(...args))
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
ipcRenderer.on('fullscreenChanged', onFullscreenChanged)
ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged)
const tc = controllers.torrent()
ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args))
ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args))
ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args))
ipcRenderer.on('wt-progress', (e, ...args) => tc.torrentProgress(...args))
ipcRenderer.on('wt-file-modtimes', (e, ...args) => tc.torrentFileModtimes(...args))
ipcRenderer.on('wt-file-saved', (e, ...args) => tc.torrentFileSaved(...args))
ipcRenderer.on('wt-poster', (e, ...args) => tc.torrentPosterSaved(...args))
ipcRenderer.on('wt-audio-metadata', (e, ...args) => tc.torrentAudioMetadata(...args))
ipcRenderer.on('wt-server-running', (e, ...args) => tc.torrentServerRunning(...args))
ipcRenderer.on('wt-uncaught-error', (e, err) => telemetry.logUncaughtError('webtorrent', err))
ipcRenderer.send('ipcReady')
State.on('stateSaved', () => ipcRenderer.send('stateSaved'))
}
// Quits any modal popovers and returns to the torrent list screen
function backToList () {
// Exit any modals and screens with a back button
state.modal = null
state.location.backToFirst(function () {
// If we were already on the torrent list, scroll to the top
const contentTag = document.querySelector('.content')
if (contentTag) contentTag.scrollTop = 0
})
}
// Quits modals, full screen, or goes back. Happens when the user hits ESC
function escapeBack () {
if (state.modal) {
dispatch('exitModal')
} else if (state.window.isFullScreen) {
dispatch('toggleFullScreen')
} else {
dispatch('back')
}
}
// Starts all torrents that aren't paused on program startup
function resumeTorrents () {
state.saved.torrents
.map((torrentSummary) => {
// Torrent keys are ephemeral, reassigned each time the app runs.
// On startup, give all torrents a key, even the ones that are paused.
torrentSummary.torrentKey = state.nextTorrentKey++
return torrentSummary
})
.filter((s) => s.status !== 'paused')
.forEach((s) => controllers.torrentList().startTorrentingSummary(s.torrentKey))
}
// Set window dimensions to match video dimensions or fill the screen
function setDimensions (dimensions) {
// Don't modify the window size if it's already maximized
if (electron.remote.getCurrentWindow().isMaximized()) {
state.window.bounds = null
return
}
// Save the bounds of the window for later. See restoreBounds()
state.window.bounds = {
x: window.screenX,
y: window.screenY,
width: window.outerWidth,
height: window.outerHeight
}
state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
// Limit window size to screen size
const screenWidth = window.screen.width
const screenHeight = window.screen.height
const aspectRatio = dimensions.width / dimensions.height
const scaleFactor = Math.min(
Math.min(screenWidth / dimensions.width, 1),
Math.min(screenHeight / dimensions.height, 1)
)
const width = Math.max(
Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH
)
const height = Math.max(
Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT
)
ipcRenderer.send('setAspectRatio', aspectRatio)
ipcRenderer.send('setBounds', {contentBounds: true, x: null, y: null, width, height})
state.playing.aspectRatio = aspectRatio
}
// Called when the user adds files (.torrent, files to seed, subtitles) to the app
// via any method (drag-drop, drag to app icon, command line)
function onOpen (files) {
if (!Array.isArray(files)) files = [ files ]
const url = state.location.url()
const allTorrents = files.every(TorrentPlayer.isTorrent)
const allSubtitles = files.every(controllers.subtitles().isSubtitle)
if (allTorrents) {
// Drop torrents onto the app: go to home screen, add torrents, no matter what
dispatch('backToList')
// All .torrent files? Add them.
files.forEach((file) => controllers.torrentList().addTorrent(file))
} else if (url === 'player' && allSubtitles) {
// Drop subtitles onto a playing video: add subtitles
controllers.subtitles().addSubtitles(files, true)
} else if (url === 'home') {
// Drop files onto home screen: show Create Torrent
state.modal = null
controllers.torrentList().showCreateTorrent(files)
} else {
// Drop files onto any other screen: show error
return onError('Please go back to the torrent list before creating a new torrent.')
}
update()
}
function onError (err) {
console.error(err.stack || err)
sound.play('ERROR')
state.errors.push({
time: new Date().getTime(),
message: err.message || err
})
update()
}
function onPaste (e) {
if (e.target.tagName.toLowerCase() === 'input') return
controllers.torrentList().addTorrent(electron.clipboard.readText())
update()
}
function onFocus (e) {
state.window.isFocused = true
state.dock.badge = 0
update()
}
function onBlur () {
state.window.isFocused = false
update()
}
function onVisibilityChange () {
state.window.isVisible = !document.webkitHidden
}
function onFullscreenChanged (e, isFullScreen) {
state.window.isFullScreen = isFullScreen
if (!isFullScreen) {
// Aspect ratio gets reset in fullscreen mode, so restore it (Mac)
ipcRenderer.send('setAspectRatio', state.playing.aspectRatio)
}
update()
}
function onWindowBoundsChanged (e, newBounds) {
if (state.location.url() !== 'player') {
state.saved.bounds = newBounds
dispatch('stateSave')
}
}
function checkDownloadPath () {
fs.stat(state.saved.prefs.downloadPath, function (err, stat) {
if (err) {
state.downloadPathStatus = 'missing'
return console.error(err)
}
if (stat.isDirectory()) state.downloadPathStatus = 'ok'
else state.downloadPathStatus = 'missing'
})
}

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

@@ -0,0 +1,658 @@
const React = require('react')
const Bitfield = require('bitfield')
const prettyBytes = require('prettier-bytes')
const zeroFill = require('zero-fill')
const TorrentSummary = require('../lib/torrent-summary')
const Playlist = require('../lib/playlist')
const {dispatch, dispatcher} = require('../lib/dispatcher')
const config = require('../../config')
// Shows a streaming video player. Standard features + Chromecast + Airplay
module.exports = class Player extends React.Component {
render () {
// Show the video as large as will fit in the window, play immediately
// If the video is on Chromecast or Airplay, show a title screen instead
const state = this.props.state
const showVideo = state.playing.location === 'local'
const showControls = state.playing.location !== 'external'
return (
<div
className='player'
onWheel={handleVolumeWheel}
onMouseMove={dispatcher('mediaMouseMoved')}>
{showVideo ? renderMedia(state) : renderCastScreen(state)}
{showControls ? renderPlayerControls(state) : null}
</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
function handleVolumeWheel (e) {
dispatch('changeVolume', (-e.deltaY | e.deltaX) / 500)
}
function renderMedia (state) {
if (!state.server) return
// Unfortunately, play/pause can't be done just by modifying HTML.
// Instead, grab the DOM node and play/pause it if necessary
// Get the <video> or <audio> tag
const mediaElement = document.querySelector(state.playing.type)
if (mediaElement !== null) {
if (state.playing.isPaused && !mediaElement.paused) {
mediaElement.pause()
} else if (!state.playing.isPaused && mediaElement.paused) {
mediaElement.play()
}
// When the user clicks or drags on the progress bar, jump to that position
if (state.playing.jumpToTime != null) {
mediaElement.currentTime = state.playing.jumpToTime
state.playing.jumpToTime = null
}
if (state.playing.playbackRate !== mediaElement.playbackRate) {
mediaElement.playbackRate = state.playing.playbackRate
}
// Recover previous volume
if (state.previousVolume !== null && isFinite(state.previousVolume)) {
mediaElement.volume = state.previousVolume
state.previousVolume = null
}
// Set volume
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
mediaElement.volume = state.playing.setVolume
state.playing.setVolume = null
}
// Switch to the newly added subtitle track, if available
const tracks = mediaElement.textTracks || []
for (let j = 0; j < tracks.length; j++) {
const isSelectedTrack = j === state.playing.subtitles.selectedIndex
tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden'
}
// Save video position
const file = state.getPlayingFileSummary()
file.currentTime = state.playing.currentTime = mediaElement.currentTime
file.duration = state.playing.duration = mediaElement.duration
// Save selected subtitle
if (state.playing.subtitles.selectedIndex !== -1) {
const index = state.playing.subtitles.selectedIndex
file.selectedSubtitle = state.playing.subtitles.tracks[index].filePath
} else if (file.selectedSubtitle != null) {
delete file.selectedSubtitle
}
state.playing.volume = mediaElement.volume
}
// Add subtitles to the <video> tag
const trackTags = []
if (state.playing.subtitles.selectedIndex >= 0) {
for (let i = 0; i < state.playing.subtitles.tracks.length; i++) {
const track = state.playing.subtitles.tracks[i]
const isSelected = state.playing.subtitles.selectedIndex === i
trackTags.push(
<track
key={i}
default={isSelected ? 'default' : ''}
label={track.label}
type='subtitles'
src={track.buffer} />
)
}
}
// Create the <audio> or <video> tag
const MediaTagName = state.playing.type
const mediaTag = (
<MediaTagName
src={Playlist.getCurrentLocalURL(state)}
onDoubleClick={dispatcher('toggleFullScreen')}
onLoadedMetadata={onLoadedMetadata}
onEnded={onEnded}
onStalled={dispatcher('mediaStalled')}
onError={dispatcher('mediaError')}
onTimeUpdate={dispatcher('mediaTimeUpdate')}
onEncrypted={dispatcher('mediaEncrypted')}
onCanPlay={onCanPlay}>
{trackTags}
</MediaTagName>
)
// Show the media.
return (
<div
key='letterbox'
className='letterbox'
onMouseMove={dispatcher('mediaMouseMoved')}>
{mediaTag}
{renderOverlay(state)}
</div>
)
// As soon as we know the video dimensions, resize the window
function onLoadedMetadata (e) {
if (state.playing.type !== 'video') return
const video = e.target
const dimensions = {
width: video.videoWidth,
height: video.videoHeight
}
dispatch('setDimensions', dimensions)
}
function onEnded (e) {
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) {
const elem = e.target
if (state.playing.type === 'video' &&
elem.webkitVideoDecodedByteCount === 0) {
dispatch('mediaError', 'Video codec unsupported')
} else if (elem.webkitAudioDecodedByteCount === 0) {
dispatch('mediaError', 'Audio codec unsupported')
} else {
dispatch('mediaSuccess')
elem.play()
}
}
}
function renderOverlay (state) {
const elems = []
const audioMetadataElem = renderAudioMetadata(state)
const spinnerElem = renderLoadingSpinner(state)
if (audioMetadataElem) elems.push(audioMetadataElem)
if (spinnerElem) elems.push(spinnerElem)
// Video fills the window, centered with black bars if necessary
// Audio gets a static poster image and a summary of the file metadata.
let style
if (state.playing.type === 'audio') {
style = { backgroundImage: cssBackgroundImagePoster(state) }
} else if (elems.length !== 0) {
style = { backgroundImage: cssBackgroundImageDarkGradient() }
} else {
// Video playing, so no spinner. No overlay needed
return
}
return (
<div key='overlay' className='media-overlay-background' style={style}>
<div className='media-overlay'>{elems}</div>
</div>
)
}
function renderAudioMetadata (state) {
const fileSummary = state.getPlayingFileSummary()
if (!fileSummary.audioInfo) return
const info = fileSummary.audioInfo
// Get audio track info
let title = info.title
if (!title) {
title = fileSummary.name
}
let artist = info.artist && info.artist[0]
let album = info.album
if (album && info.year && !album.includes(info.year)) {
album += ' (' + info.year + ')'
}
let track
if (info.track && info.track.no && info.track.of) {
track = info.track.no + ' of ' + info.track.of
}
// Show a small info box in the middle of the screen with title/album/etc
const elems = []
if (artist) {
elems.push((
<div key='artist' className='audio-artist'>
<label>Artist</label>{artist}
</div>
))
}
if (album) {
elems.push((
<div key='album' className='audio-album'>
<label>Album</label>{album}
</div>
))
}
if (track) {
elems.push((
<div key='track' className='audio-track'>
<label>Track</label>{track}
</div>
))
}
// Align the title with the other info, if available. Otherwise, center title
const emptyLabel = (<label />)
elems.unshift((
<div key='title' className='audio-title'>
{elems.length ? emptyLabel : undefined}{title}
</div>
))
return (<div key='audio-metadata' className='audio-metadata'>{elems}</div>)
}
function renderLoadingSpinner (state) {
if (state.playing.isPaused) return
const isProbablyStalled = state.playing.isStalled ||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000)
if (!isProbablyStalled) return
const prog = state.getPlayingTorrentSummary().progress || {}
let fileProgress = 0
if (prog.files) {
const file = prog.files[state.playing.fileIndex]
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
}
return (
<div key='loading' className='media-stalled'>
<div key='loading-spinner' className='loading-spinner'>&nbsp;</div>
<div key='loading-progress' className='loading-status ellipsis'>
<span className='progress'>{fileProgress}%</span> downloaded,
<span> {prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span> {prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div>
</div>
)
}
function renderCastScreen (state) {
let castIcon, castType, isCast
if (state.playing.location.startsWith('chromecast')) {
castIcon = 'cast_connected'
castType = 'Chromecast'
isCast = true
} else if (state.playing.location.startsWith('airplay')) {
castIcon = 'airplay'
castType = 'AirPlay'
isCast = true
} else if (state.playing.location.startsWith('dlna')) {
castIcon = 'tv'
castType = 'DLNA'
isCast = true
} else if (state.playing.location === 'external') {
castIcon = 'tv'
castType = state.getExternalPlayerName()
isCast = false
} else if (state.playing.location === 'error') {
castIcon = 'error_outline'
castType = 'Error'
isCast = false
}
const isStarting = state.playing.location.endsWith('-pending')
const castName = state.playing.castName
let castStatus
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
else castStatus = ''
// Show a nice title image, if possible
const style = {
backgroundImage: cssBackgroundImagePoster(state)
}
return (
<div key='cast' className='letterbox' style={style}>
<div className='cast-screen'>
<i className='icon'>{castIcon}</i>
<div key='type' className='cast-type'>{castType}</div>
<div key='status' className='cast-status'>{castStatus}</div>
</div>
</div>
)
}
function renderCastOptions (state) {
if (!state.devices.castMenu) return
const {location, devices} = state.devices.castMenu
const player = state.devices[location]
const items = devices.map(function (device, ix) {
const isSelected = player.device === device
const name = device.name
return (
<li key={ix} onClick={dispatcher('selectCastDevice', ix)}>
<i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
{name}
</li>
)
})
return (
<ul key='cast-options' className='options-list'>
{items}
</ul>
)
}
function renderSubtitleOptions (state) {
const subtitles = state.playing.subtitles
if (!subtitles.tracks.length || !subtitles.showMenu) return
const items = subtitles.tracks.map(function (track, ix) {
const isSelected = state.playing.subtitles.selectedIndex === ix
return (
<li key={ix} onClick={dispatcher('selectSubtitle', ix)}>
<i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
{track.label}
</li>
)
})
const noneSelected = state.playing.subtitles.selectedIndex === -1
const noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked')
return (
<ul key='subtitle-options' className='options-list'>
{items}
<li onClick={dispatcher('selectSubtitle', -1)}>
<i className='icon'>{noneClass}</i>
None
</li>
</ul>
)
}
function renderPlayerControls (state) {
const positionPercent = 100 * state.playing.currentTime / state.playing.duration
const playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
const captionsClass = state.playing.subtitles.tracks.length === 0
? 'disabled'
: state.playing.subtitles.selectedIndex >= 0
? 'active'
: ''
const prevClass = Playlist.hasPrevious(state) ? '' : 'disabled'
const nextClass = Playlist.hasNext(state) ? '' : 'disabled'
const elements = [
<div key='playback-bar' className='playback-bar'>
{renderLoadingBar(state)}
<div
key='cursor'
className='playback-cursor'
style={playbackCursorStyle} />
<div
key='scrub-bar'
className='scrub-bar'
draggable='true'
onDragStart={handleDragStart}
onClick={handleScrub}
onDrag={handleScrub} />
</div>,
<i
key='skip-previous'
className={'icon skip-previous float-left ' + prevClass}
onClick={dispatcher('previousTrack')}>
skip_previous
</i>,
<i
key='play'
className='icon play-pause float-left'
onClick={dispatcher('playPause')}>
{state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>,
<i
key='skip-next'
className={'icon skip-next float-left ' + nextClass}
onClick={dispatcher('nextTrack')}>
skip_next
</i>,
<i
key='fullscreen'
className='icon fullscreen float-right'
onClick={dispatcher('toggleFullScreen')}>
{state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
</i>
]
if (state.playing.type === 'video') {
// Show closed captions icon
elements.push((
<i
key='subtitles'
className={'icon closed-caption float-right ' + captionsClass}
onClick={handleSubtitles}>
closed_caption
</i>
))
}
// If we've detected a Chromecast or AppleTV, the user can play video there
const castTypes = ['chromecast', 'airplay', 'dlna']
const isCastingAnywhere = castTypes.some(
(castType) => state.playing.location.startsWith(castType))
// Add the cast buttons. Icons for each cast type, connected/disconnected:
const buttonIcons = {
'chromecast': {true: 'cast_connected', false: 'cast'},
'airplay': {true: 'airplay', false: 'airplay'},
'dlna': {true: 'tv', false: 'tv'}
}
castTypes.forEach(function (castType) {
// Do we show this button (eg. the Chromecast button) at all?
const isCasting = state.playing.location.startsWith(castType)
const player = state.devices[castType]
if ((!player || player.getDevices().length === 0) && !isCasting) return
// Show the button. Three options for eg the Chromecast button:
let buttonClass, buttonHandler
if (isCasting) {
// Option 1: we are currently connected to Chromecast. Button stops the cast.
buttonClass = 'active'
buttonHandler = dispatcher('stopCasting')
} else if (isCastingAnywhere) {
// Option 2: we are currently connected somewhere else. Button disabled.
buttonClass = 'disabled'
buttonHandler = undefined
} else {
// Option 3: we are not connected anywhere. Button opens Chromecast menu.
buttonClass = ''
buttonHandler = dispatcher('toggleCastMenu', castType)
}
const buttonIcon = buttonIcons[castType][isCasting]
elements.push((
<i
key={castType}
className={'icon device float-right ' + buttonClass}
onClick={buttonHandler}>
{buttonIcon}
</i>
))
})
// Render volume slider
const volume = state.playing.volume
const volumeIcon = 'volume_' + (
volume === 0 ? 'off'
: volume < 0.3 ? 'mute'
: volume < 0.6 ? 'down'
: 'up')
const volumeStyle = {
background: '-webkit-gradient(linear, left top, right top, ' +
'color-stop(' + (volume * 100) + '%, #eee), ' +
'color-stop(' + (volume * 100) + '%, #727272))'
}
elements.push((
<div key='volume' className='volume float-left'>
<i
className='icon volume-icon float-left'
onMouseDown={handleVolumeMute}>
{volumeIcon}
</i>
<input
className='volume-slider float-right'
type='range' min='0' max='1' step='0.05'
value={volume}
onChange={handleVolumeScrub}
style={volumeStyle} />
</div>
))
// Show video playback progress
const currentTimeStr = formatTime(state.playing.currentTime)
const durationStr = formatTime(state.playing.duration)
elements.push((
<span key='time' className='time float-left'>
{currentTimeStr} / {durationStr}
</span>
))
// Render playback rate
if (state.playing.playbackRate !== 1) {
elements.push((
<span key='rate' className='rate float-left'>
{state.playing.playbackRate}x
</span>
))
}
return (
<div key='controls' className='controls'
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
{elements}
{renderCastOptions(state)}
{renderSubtitleOptions(state)}
</div>
)
function handleDragStart (e) {
// Prevent the cursor from changing, eg to a green + icon on Mac
if (e.dataTransfer) {
const dt = e.dataTransfer
dt.effectAllowed = 'none'
}
}
// Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) {
if (!e.clientX) return
dispatch('mediaMouseMoved')
const windowWidth = document.querySelector('body').clientWidth
const fraction = e.clientX / windowWidth
const position = fraction * state.playing.duration /* seconds */
dispatch('skipTo', position)
}
// Handles volume muting and Unmuting
function handleVolumeMute (e) {
if (state.playing.volume === 0.0) {
dispatch('setVolume', 1.0)
} else {
dispatch('setVolume', 0.0)
}
}
// Handles volume slider scrub
function handleVolumeScrub (e) {
dispatch('setVolume', e.target.value)
}
function handleSubtitles (e) {
if (!state.playing.subtitles.tracks.length || e.ctrlKey || e.metaKey) {
// if no subtitles available select it
dispatch('openSubtitles')
} else {
dispatch('toggleSubtitlesMenu')
}
}
}
// Renders the loading bar. Shows which parts of the torrent are loaded, which
// can be 'spongey' / non-contiguous
function renderLoadingBar (state) {
if (config.IS_TEST) return // Don't integration test the loading bar. Screenshots won't match.
const torrentSummary = state.getPlayingTorrentSummary()
if (!torrentSummary.progress) {
return []
}
// Find all contiguous parts of the torrent which are loaded
const prog = torrentSummary.progress
const fileProg = prog.files[state.playing.fileIndex]
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) {
parts.push({start: i - fileProg.startPiece, count: 1})
} else if (partPresent) {
parts[parts.length - 1].count++
}
lastPiecePresent = partPresent
}
// Output some bars to show which parts of the file are loaded
const loadingBarElems = parts.map(function (part, i) {
const style = {
left: (100 * part.start / fileProg.numPieces) + '%',
width: (100 * part.count / fileProg.numPieces) + '%'
}
return (<div key={i} className='loading-bar-part' style={style} />)
})
return (<div key='loading-bar' className='loading-bar'>{loadingBarElems}</div>)
}
// Returns the CSS background-image string for a poster image + dark vignette
function cssBackgroundImagePoster (state) {
const torrentSummary = state.getPlayingTorrentSummary()
const posterPath = TorrentSummary.getPosterPath(torrentSummary)
if (!posterPath) return ''
return cssBackgroundImageDarkGradient() + `, url('${posterPath}')`
}
function cssBackgroundImageDarkGradient () {
return 'radial-gradient(circle at center, ' +
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
}
function formatTime (time) {
if (typeof time !== 'number' || Number.isNaN(time)) {
return '0:00'
}
let hours = Math.floor(time / 3600)
let minutes = Math.floor(time % 3600 / 60)
if (hours > 0) {
minutes = zeroFill(2, minutes)
}
let seconds = zeroFill(2, Math.floor(time % 60))
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
}

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