Compare commits
275 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678f961110 | ||
|
|
f0464c44fd | ||
|
|
b323ee24c4 | ||
|
|
38aad2ee9c | ||
|
|
b69ca93d20 | ||
|
|
e21a039e70 | ||
|
|
11f8e428a0 | ||
|
|
704455c432 | ||
|
|
c25bee755c | ||
|
|
373d598c29 | ||
|
|
b8effffa96 | ||
|
|
ca6a7917ce | ||
|
|
033bdf7908 | ||
|
|
6fe03aa325 | ||
|
|
b93f41f564 | ||
|
|
3f6cc97a02 | ||
|
|
0bda5358bd | ||
|
|
e0af554caa | ||
|
|
b98f8476f5 | ||
|
|
30732305ff | ||
|
|
14102ab3e6 | ||
|
|
df16b14586 | ||
|
|
9dcbc1b1f6 | ||
|
|
d806fd502f | ||
|
|
db650caf18 | ||
|
|
2b6c9ffcdb | ||
|
|
1d4b8ab67d | ||
|
|
6404168bee | ||
|
|
6613366cff | ||
|
|
74349129f4 | ||
|
|
cdc2c1d718 | ||
|
|
f8cc155650 | ||
|
|
b6bdeab50b | ||
|
|
f528f6033f | ||
|
|
ef1bc13c38 | ||
|
|
2f54feac74 | ||
|
|
75d30baaa5 | ||
|
|
990fb57839 | ||
|
|
1883341ddb | ||
|
|
5f7cece6d1 | ||
|
|
89e77d34f4 | ||
|
|
5eeb8fd6fc | ||
|
|
808fca031a | ||
|
|
6b3c1e3802 | ||
|
|
f488ef7597 | ||
|
|
2c179c7465 | ||
|
|
f1cf37200e | ||
|
|
d2da6881d6 | ||
|
|
9c25de58de | ||
|
|
f9aeb676b4 | ||
|
|
396d769bc8 | ||
|
|
83901eecba | ||
|
|
019728cff5 | ||
|
|
a96241d151 | ||
|
|
ebc9771be5 | ||
|
|
0c75bac364 | ||
|
|
3f7e2c1e4a | ||
|
|
10bf38bdf0 | ||
|
|
02508d7d9e | ||
|
|
5cb295f722 | ||
|
|
b08d273996 | ||
|
|
1e27d1803a | ||
|
|
9bc018cc02 | ||
|
|
73cdfc6d45 | ||
|
|
1afd650997 | ||
|
|
9c8eabb46c | ||
|
|
b39884e68f | ||
|
|
451d457426 | ||
|
|
82853aa017 | ||
|
|
157226f75b | ||
|
|
509691a85a | ||
|
|
8b3aee7e2d | ||
|
|
4025e669eb | ||
|
|
1a01d7ed92 | ||
|
|
b4976d27f2 | ||
|
|
173d8444d7 | ||
|
|
aa150b76a5 | ||
|
|
e2b5e28e07 | ||
|
|
1ad07d9977 | ||
|
|
8ba4056894 | ||
|
|
9ad0316dff | ||
|
|
854aae7dc5 | ||
|
|
5b021cd42e | ||
|
|
33417d9b7e | ||
|
|
275184214a | ||
|
|
1f9adbd3cf | ||
|
|
092c207dce | ||
|
|
603c24faed | ||
|
|
f259b32cce | ||
|
|
eba9aa3e17 | ||
|
|
905eb1611e | ||
|
|
6d4b8c3c26 | ||
|
|
6a46609cca | ||
|
|
e872282221 | ||
|
|
24ac5af5b4 | ||
|
|
0ee92fb632 | ||
|
|
7cbc12c6ff | ||
|
|
60c82c73cd | ||
|
|
78790e73c7 | ||
|
|
bf464de16f | ||
|
|
0589963eed | ||
|
|
b79971eea5 | ||
|
|
d1e557f054 | ||
|
|
93ddb8d638 | ||
|
|
06fdd80845 | ||
|
|
b0b26f8300 | ||
|
|
1db890f5e7 | ||
|
|
0f80f96023 | ||
|
|
2d3673ea33 | ||
|
|
c28260611e | ||
|
|
b5dd00007a | ||
|
|
ac39264f3d | ||
|
|
667a04a41d | ||
|
|
51a9b2ea9b | ||
|
|
842ee5ca3c | ||
|
|
2cc67dbda7 | ||
|
|
70bc32614b | ||
|
|
9bf44d7d7e | ||
|
|
f48ecb87b2 | ||
|
|
1765aba681 | ||
|
|
c6063c759e | ||
|
|
bb4db2cede | ||
|
|
7c36898f78 | ||
|
|
23e8cdf216 | ||
|
|
5ffd4123a1 | ||
|
|
27e3c14f10 | ||
|
|
d57bfb825a | ||
|
|
0809e20a6e | ||
|
|
1ec305162e | ||
|
|
45d46d7ae8 | ||
|
|
adb41736d5 | ||
|
|
09d6fa550a | ||
|
|
75cc7383cb | ||
|
|
4d48b9e7c1 | ||
|
|
563e1ca0ba | ||
|
|
0fa3b678b0 | ||
|
|
8420c65d25 | ||
|
|
3232e96f6e | ||
|
|
110e25af73 | ||
|
|
8233faf518 | ||
|
|
11eb603930 | ||
|
|
1d55c51a16 | ||
|
|
447a7e514e | ||
|
|
fd433784bd | ||
|
|
4e2b196b26 | ||
|
|
14fcbfcced | ||
|
|
4126d15821 | ||
|
|
0d1cc72798 | ||
|
|
c42eb789df | ||
|
|
c1dd0b31cf | ||
|
|
9afed7fb1b | ||
|
|
a8239895c6 | ||
|
|
7531ab4623 | ||
|
|
9b36f9cb22 | ||
|
|
29f8ef6b72 | ||
|
|
7752e41416 | ||
|
|
756ccd1921 | ||
|
|
39ae0343fc | ||
|
|
67409214a4 | ||
|
|
b3d0edfec1 | ||
|
|
87b9dba568 | ||
|
|
91a4c0cff5 | ||
|
|
9670dc7a81 | ||
|
|
4f2c5b946d | ||
|
|
fc53c68dd9 | ||
|
|
03bc4cf9b1 | ||
|
|
c637878603 | ||
|
|
91e61f6cd4 | ||
|
|
9f66418073 | ||
|
|
2c3d667675 | ||
|
|
2ab93f2309 | ||
|
|
05ce20303c | ||
|
|
5e997d1bbf | ||
|
|
d95e5b02d6 | ||
|
|
536f04985f | ||
|
|
43a81f725f | ||
|
|
8373e69d09 | ||
|
|
c5ed5fabd8 | ||
|
|
8ba4dadb10 | ||
|
|
112600f5c3 | ||
|
|
c2f869b362 | ||
|
|
2590e0effc | ||
|
|
b417ef5b03 | ||
|
|
1733a506c0 | ||
|
|
ac05cc4387 | ||
|
|
904f337713 | ||
|
|
febad56497 | ||
|
|
cb71de2313 | ||
|
|
6891ef1a0d | ||
|
|
6c68645b0f | ||
|
|
767ca71f7d | ||
|
|
1605d23509 | ||
|
|
90a0201e38 | ||
|
|
80983c2058 | ||
|
|
ad62bbd9d3 | ||
|
|
1b3b6fef10 | ||
|
|
6e1ff18eb9 | ||
|
|
5796ba32a6 | ||
|
|
2eb33e5f0c | ||
|
|
7c14d8c909 | ||
|
|
fe4c1b0ee8 | ||
|
|
d21396c618 | ||
|
|
9df51aec49 | ||
|
|
734b0731a1 | ||
|
|
d20786cd69 | ||
|
|
793ea79cab | ||
|
|
073a86ecbd | ||
|
|
7b4fd57a94 | ||
|
|
f86ca0a168 | ||
|
|
0d3c18d3bc | ||
|
|
a4a31d0860 | ||
|
|
946bba19a9 | ||
|
|
2a1e987d42 | ||
|
|
fbcf718440 | ||
|
|
18aadf9d23 | ||
|
|
cd575d2005 | ||
|
|
3cfdf857cf | ||
|
|
c59abb251b | ||
|
|
2fc1034cc5 | ||
|
|
a8fd60f46e | ||
|
|
0cbae6b4d5 | ||
|
|
dc7ccb3956 | ||
|
|
a420936657 | ||
|
|
dcab7f72d4 | ||
|
|
d0733d3370 | ||
|
|
a695f7c2d7 | ||
|
|
7677bff6d4 | ||
|
|
c7626997de | ||
|
|
7b8751312a | ||
|
|
6d664f2086 | ||
|
|
4ebf7e25b7 | ||
|
|
54e70e7158 | ||
|
|
b950829de3 | ||
|
|
a489397f84 | ||
|
|
897dac354d | ||
|
|
beb4af1311 | ||
|
|
f0aeab0207 | ||
|
|
be1314422d | ||
|
|
c15711aae8 | ||
|
|
1668c4c614 | ||
|
|
7050ee849b | ||
|
|
dfe1e3b631 | ||
|
|
50c47dd657 | ||
|
|
a373141a93 | ||
|
|
24f5856649 | ||
|
|
f85e0a61b1 | ||
|
|
4319ef2853 | ||
|
|
c3a27dbebe | ||
|
|
bac43509d2 | ||
|
|
59b012e527 | ||
|
|
c615e285db | ||
|
|
1aca9fe753 | ||
|
|
349c5ee22e | ||
|
|
c44943cef7 | ||
|
|
91a1ab4a56 | ||
|
|
7a61b52d64 | ||
|
|
e5df96c82e | ||
|
|
770327c3fa | ||
|
|
4bdc6e3d65 | ||
|
|
3e19cdfb0b | ||
|
|
2043dc2161 | ||
|
|
a9e36472c5 | ||
|
|
4df4f9b2ad | ||
|
|
4ad55173a5 | ||
|
|
b9c82dd6b2 | ||
|
|
8333f4893f | ||
|
|
f071965ae8 | ||
|
|
a4fa9ac666 | ||
|
|
939ee555b7 | ||
|
|
4799a032e5 | ||
|
|
b2d2a6a7a5 | ||
|
|
7676106914 | ||
|
|
fe5ea31f2c | ||
|
|
8f03ecedaa | ||
|
|
db20bd8eaf |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
npm-debug.log.*
|
||||
|
||||
2
.nodemonignore
Normal file
2
.nodemonignore
Normal file
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
dist/
|
||||
@@ -1,4 +1,4 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 'node'
|
||||
install: npm install standard
|
||||
install: npm install standard depcheck walk-sync
|
||||
|
||||
50
AUTHORS.md
50
AUTHORS.md
@@ -2,25 +2,35 @@
|
||||
|
||||
#### 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>
|
||||
- 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)
|
||||
|
||||
#### Generated by bin/update-authors.sh.
|
||||
|
||||
149
CHANGELOG.md
149
CHANGELOG.md
@@ -1,5 +1,154 @@
|
||||
# WebTorrent Desktop Version History
|
||||
|
||||
## 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
|
||||
|
||||
## v0.8.1 - 2016-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- New URI handler: stream-magnet
|
||||
|
||||
### Fixed
|
||||
|
||||
- DLNA crashing bug
|
||||
|
||||
## v0.8.0 - 2016-06-23
|
||||
|
||||
### Added
|
||||
|
||||
- Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use
|
||||
- Telemetry: send basic data, plus stats on how often the play button works
|
||||
- Make posters from jpeg files, not just jpg
|
||||
- Support .wmv video via Play in VLC
|
||||
- Windows thumbnail bar with a play/pause button
|
||||
|
||||
### Changed
|
||||
|
||||
- Nicer modal styles
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows tray icon now stays in the right state
|
||||
|
||||
## v0.7.2 - 2016-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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.
|
||||
|
||||
66
README.md
66
README.md
@@ -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,48 @@
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Package the app
|
||||
|
||||
Builds app binaries for Mac, Linux, and Windows.
|
||||
|
||||
```
|
||||
$ npm run package
|
||||
@@ -50,30 +67,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,9 +102,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 what OSs are users are on, and how well the play button works (how often does it succeed? time out? show a missing codec error?). The app never sends personally identifying or other private info.
|
||||
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 any personally identifying information, nor does it track which
|
||||
torrents you add.
|
||||
|
||||
### Code Style
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
10
bin/clean.js
10
bin/clean.js
@@ -10,11 +10,17 @@ var os = require('os')
|
||||
var path = require('path')
|
||||
var rimraf = require('rimraf')
|
||||
|
||||
var config = require('../config')
|
||||
var handlers = require('../main/handlers')
|
||||
var config = require('../src/config')
|
||||
var handlers = require('../src/main/handlers')
|
||||
|
||||
// First, remove generated files
|
||||
rimraf.sync('build/')
|
||||
rimraf.sync('dist/')
|
||||
|
||||
// Remove any saved configuration
|
||||
rimraf.sync(config.CONFIG_PATH)
|
||||
|
||||
// Remove any temporary files
|
||||
var tmpPath
|
||||
try {
|
||||
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var electron = require('electron-prebuilt')
|
||||
var electron = require('electron')
|
||||
var cp = require('child_process')
|
||||
var path = require('path')
|
||||
|
||||
|
||||
46
bin/extra-lint.js
Executable file
46
bin/extra-lint.js
Executable 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
|
||||
var files = walkSync('src', {globs: ['**/*.js']})
|
||||
console.log('Running extra-lint on ' + files.length + ' files...')
|
||||
|
||||
// Read each file, line by line
|
||||
files.forEach(function (file) {
|
||||
var filepath = path.join('src', file)
|
||||
var lines = fs.readFileSync(filepath, 'utf8').split('\n')
|
||||
|
||||
lines.forEach(function (line, i) {
|
||||
var 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!')
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
var config = require('../config')
|
||||
var config = require('../src/config')
|
||||
var open = require('open')
|
||||
|
||||
open(config.CONFIG_PATH)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Builds app binaries for OS X, Linux, and Windows.
|
||||
* Builds app binaries for Mac, Linux, and Windows.
|
||||
*/
|
||||
|
||||
var cp = require('child_process')
|
||||
@@ -15,10 +15,11 @@ var rimraf = require('rimraf')
|
||||
var series = require('run-series')
|
||||
var zip = require('cross-zip')
|
||||
|
||||
var config = require('../config')
|
||||
var config = require('../src/config')
|
||||
var pkg = require('../package.json')
|
||||
|
||||
var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
|
||||
var BUILD_PATH = path.join(config.ROOT_PATH, 'build')
|
||||
var DIST_PATH = path.join(config.ROOT_PATH, 'dist')
|
||||
|
||||
var argv = minimist(process.argv.slice(2), {
|
||||
@@ -36,6 +37,12 @@ var argv = minimist(process.argv.slice(2), {
|
||||
|
||||
function build () {
|
||||
rimraf.sync(DIST_PATH)
|
||||
rimraf.sync(BUILD_PATH)
|
||||
|
||||
console.log('Babel: Building JSX...')
|
||||
cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' })
|
||||
console.log('Babel: Built JSX.')
|
||||
|
||||
var platform = argv._[0]
|
||||
if (platform === 'darwin') {
|
||||
buildDarwin(printDone)
|
||||
@@ -56,11 +63,11 @@ function build () {
|
||||
|
||||
var all = {
|
||||
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
|
||||
// property on Windows, and `NSHumanReadableCopyright` on OS X.
|
||||
// property on Windows, and `NSHumanReadableCopyright` on Mac.
|
||||
'app-copyright': config.APP_COPYRIGHT,
|
||||
|
||||
// The release version of the application. Maps to the `ProductVersion` metadata
|
||||
// property on Windows, and `CFBundleShortVersionString` on OS X.
|
||||
// property on Windows, and `CFBundleShortVersionString` on Mac.
|
||||
'app-version': pkg.version,
|
||||
|
||||
// Package the application's source code into an archive, using Electron's archive
|
||||
@@ -73,7 +80,7 @@ var all = {
|
||||
'asar-unpack': 'WebTorrent*',
|
||||
|
||||
// The build version of the application. Maps to the FileVersion metadata property on
|
||||
// Windows, and CFBundleVersion on OS X. Note: Windows requires the build version to
|
||||
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
|
||||
// start with a number. We're using the version of the underlying WebTorrent library.
|
||||
'build-version': require('webtorrent/package.json').version,
|
||||
|
||||
@@ -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,24 +105,24 @@ 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
|
||||
// Build for Mac
|
||||
platform: 'darwin',
|
||||
|
||||
// Build 64 bit binaries only.
|
||||
arch: 'x64',
|
||||
|
||||
// The bundle identifier to use in the application's plist (OS X only).
|
||||
// The bundle identifier to use in the application's plist (Mac only).
|
||||
'app-bundle-id': 'io.webtorrent.webtorrent',
|
||||
|
||||
// The application category type, as shown in the Finder via "View" -> "Arrange by
|
||||
// Application Category" when viewing the Applications directory (OS X only).
|
||||
// Application Category" when viewing the Applications directory (Mac only).
|
||||
'app-category-type': 'public.app-category.utilities',
|
||||
|
||||
// The bundle identifier to use in the application helper's plist (OS X only).
|
||||
// The bundle identifier to use in the application helper's plist (Mac only).
|
||||
'helper-bundle-id': 'io.webtorrent.webtorrent-helper',
|
||||
|
||||
// Application icon.
|
||||
@@ -171,10 +178,10 @@ build()
|
||||
function buildDarwin (cb) {
|
||||
var plist = require('plist')
|
||||
|
||||
console.log('OS X: Packaging electron...')
|
||||
console.log('Mac: Packaging electron...')
|
||||
electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
|
||||
if (err) return cb(err)
|
||||
console.log('OS X: Packaged electron. ' + buildPath)
|
||||
console.log('Mac: Packaged electron. ' + buildPath)
|
||||
|
||||
var appPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
var contentsPath = path.join(appPath, 'Contents')
|
||||
@@ -206,6 +213,12 @@ function buildDarwin (cb) {
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Magnet URL',
|
||||
CFBundleURLSchemes: [ 'magnet' ]
|
||||
},
|
||||
{
|
||||
CFBundleTypeRole: 'Editor',
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Stream-Magnet URL',
|
||||
CFBundleURLSchemes: [ 'stream-magnet' ]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -255,9 +268,9 @@ function buildDarwin (cb) {
|
||||
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
|
||||
* the same author as the current version.
|
||||
* - So users will not a see a warning about the app coming from an "Unidentified
|
||||
* Developer" when they open it for the first time (OS X Gatekeeper).
|
||||
* Developer" when they open it for the first time (Mac Gatekeeper).
|
||||
*
|
||||
* To sign an OS X app for distribution outside the App Store, the following are
|
||||
* To sign an Mac app for distribution outside the App Store, the following are
|
||||
* required:
|
||||
* - Xcode
|
||||
* - Xcode Command Line Tools (xcode-select --install)
|
||||
@@ -269,10 +282,10 @@ function buildDarwin (cb) {
|
||||
verbose: true
|
||||
}
|
||||
|
||||
console.log('OS X: Signing app...')
|
||||
console.log('Mac: Signing app...')
|
||||
sign(signOpts, function (err) {
|
||||
if (err) return cb(err)
|
||||
console.log('OS X: Signed app.')
|
||||
console.log('Mac: Signed app.')
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
@@ -287,24 +300,24 @@ function buildDarwin (cb) {
|
||||
|
||||
function packageZip () {
|
||||
// Create .zip file (used by the auto-updater)
|
||||
console.log('OS X: Creating zip...')
|
||||
console.log('Mac: Creating zip...')
|
||||
|
||||
var inPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log('OS X: Created zip.')
|
||||
console.log('Mac: Created zip.')
|
||||
}
|
||||
|
||||
function packageDmg (cb) {
|
||||
console.log('OS X: Creating dmg...')
|
||||
console.log('Mac: Creating dmg...')
|
||||
|
||||
var appDmg = require('appdmg')
|
||||
|
||||
var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
|
||||
rimraf.sync(targetPath)
|
||||
|
||||
// Create a .dmg (OS X disk image) file, for easy user installation.
|
||||
// Create a .dmg (Mac disk image) file, for easy user installation.
|
||||
var dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: targetPath,
|
||||
@@ -332,7 +345,7 @@ function buildDarwin (cb) {
|
||||
if (info.type === 'step-begin') console.log(info.title + '...')
|
||||
})
|
||||
dmg.once('finish', function (info) {
|
||||
console.log('OS X: Created dmg.')
|
||||
console.log('Mac: Created dmg.')
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
@@ -460,7 +473,7 @@ function buildLinux (cb) {
|
||||
info: {
|
||||
arch: destArch === 'x64' ? 'amd64' : 'i386',
|
||||
targetDir: DIST_PATH,
|
||||
depends: 'libc6 (>= 2.4)',
|
||||
depends: 'gconf2, libgtk2.0-0, libnss3, libxss1',
|
||||
scripts: {
|
||||
postinst: path.join(config.STATIC_PATH, 'linux', 'postinst'),
|
||||
prerm: path.join(config.STATIC_PATH, 'linux', 'prerm')
|
||||
@@ -471,6 +484,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.`)
|
||||
|
||||
@@ -7,4 +7,4 @@ npm run package -- --sign
|
||||
git push
|
||||
git push --tags
|
||||
npm publish
|
||||
./node_modules/.bin/gh-release
|
||||
npm run gh-release
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
start,
|
||||
stop
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var log = require('./log')
|
||||
|
||||
var blockId = 0
|
||||
|
||||
/**
|
||||
* Block the system from entering low-power (sleep) mode or turning off the
|
||||
* display.
|
||||
*/
|
||||
function start () {
|
||||
stop() // Stop the previous power saver block, if one exists.
|
||||
blockId = electron.powerSaveBlocker.start('prevent-display-sleep')
|
||||
log(`powerSaveBlocker.start: ${blockId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop blocking the system from entering low-power mode.
|
||||
*/
|
||||
function stop () {
|
||||
if (!electron.powerSaveBlocker.isStarted(blockId)) {
|
||||
return
|
||||
}
|
||||
electron.powerSaveBlocker.stop(blockId)
|
||||
log(`powerSaveBlocker.stop: ${blockId}`)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
onPlayerClose,
|
||||
onPlayerOpen
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
var windows = require('./windows')
|
||||
|
||||
function onPlayerOpen () {
|
||||
// Register play/pause media key, available on some keyboards.
|
||||
electron.globalShortcut.register(
|
||||
'MediaPlayPause',
|
||||
() => windows.main.dispatch('playPause')
|
||||
)
|
||||
}
|
||||
|
||||
function onPlayerClose () {
|
||||
// Return the media key to the OS, so other apps can use it.
|
||||
electron.globalShortcut.unregister('MediaPlayPause')
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
module.exports = {
|
||||
showPlayerThumbnailBar,
|
||||
hidePlayerThumbnailBar,
|
||||
updateThumbarButtons
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
var config = require('../config')
|
||||
|
||||
var windows = require('./windows')
|
||||
|
||||
// gets called on player open
|
||||
function showPlayerThumbnailBar () {
|
||||
updateThumbarButtons(false)
|
||||
}
|
||||
|
||||
// gets called on player close
|
||||
function hidePlayerThumbnailBar () {
|
||||
windows.main.win.setThumbarButtons([])
|
||||
}
|
||||
|
||||
function updateThumbarButtons (isPaused) {
|
||||
var icon = isPaused ? 'PlayThumbnailBarButton.png' : 'PauseThumbnailBarButton.png'
|
||||
var tooltip = isPaused ? 'Play' : 'Pause'
|
||||
var buttons = [
|
||||
{
|
||||
tooltip: tooltip,
|
||||
icon: path.join(config.STATIC_PATH, icon),
|
||||
click: function () {
|
||||
windows.main.send('dispatch', 'playPause')
|
||||
}
|
||||
}
|
||||
]
|
||||
windows.main.win.setThumbarButtons(buttons)
|
||||
}
|
||||
22
main/vlc.js
22
main/vlc.js
@@ -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))
|
||||
})
|
||||
}
|
||||
47
package.json
47
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webtorrent-desktop",
|
||||
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
|
||||
"version": "0.8.0",
|
||||
"description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.",
|
||||
"version": "0.14.0",
|
||||
"author": {
|
||||
"name": "WebTorrent, LLC",
|
||||
"email": "feross@webtorrent.io",
|
||||
@@ -15,36 +15,44 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"airplayer": "^2.0.0",
|
||||
"application-config": "^0.2.1",
|
||||
"application-config": "^1.0.0",
|
||||
"bitfield": "^1.0.2",
|
||||
"chromecasts": "^1.8.0",
|
||||
"create-torrent": "^3.24.5",
|
||||
"deep-equal": "^1.0.1",
|
||||
"dlnacasts": "^0.1.0",
|
||||
"drag-drop": "^2.11.0",
|
||||
"electron-prebuilt": "1.2.1",
|
||||
"fs-extra": "^0.27.0",
|
||||
"hyperx": "^2.0.2",
|
||||
"drag-drop": "^2.12.1",
|
||||
"electron": "1.3.3",
|
||||
"fs-extra": "^0.30.0",
|
||||
"iso-639-1": "^1.2.1",
|
||||
"languagedetect": "^1.1.1",
|
||||
"main-loop": "^3.2.0",
|
||||
"location-history": "^1.0.0",
|
||||
"material-ui": "^0.15.4",
|
||||
"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",
|
||||
"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": {
|
||||
"babel-cli": "^6.11.4",
|
||||
"babel-plugin-syntax-jsx": "^6.13.0",
|
||||
"babel-plugin-transform-es2015-destructuring": "^6.9.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.8.0",
|
||||
"babel-plugin-transform-react-jsx": "^6.8.0",
|
||||
"cross-zip": "^2.0.1",
|
||||
"depcheck": "^0.6.4",
|
||||
"electron-osx-sign": "^0.3.0",
|
||||
"electron-packager": "^7.0.0",
|
||||
"electron-winstaller": "^2.3.0",
|
||||
@@ -52,11 +60,16 @@
|
||||
"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",
|
||||
"plist": "^2.0.1",
|
||||
"rimraf": "^2.5.2",
|
||||
"run-series": "^1.1.4",
|
||||
"standard": "^7.0.0"
|
||||
"standard": "*",
|
||||
"walk-sync": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
},
|
||||
"homepage": "https://webtorrent.io",
|
||||
"keywords": [
|
||||
@@ -65,8 +78,8 @@
|
||||
"electron-app",
|
||||
"hybrid webtorrent client",
|
||||
"mad science",
|
||||
"torrent client",
|
||||
"torrent",
|
||||
"torrent client",
|
||||
"webtorrent"
|
||||
],
|
||||
"license": "MIT",
|
||||
@@ -80,11 +93,15 @@
|
||||
"url": "git://github.com/feross/webtorrent-desktop.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel --quiet src --out-dir build",
|
||||
"clean": "node ./bin/clean.js",
|
||||
"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=babel-cli,nodemon,gh-release --ignore-dirs=build,dist && node ./bin/extra-lint.js",
|
||||
"gh-release": "gh-release",
|
||||
"update-authors": "./bin/update-authors.sh",
|
||||
"watch": "nodemon --exec \"npm run start\" --ext js,pug,css"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
UnplayableError
|
||||
}
|
||||
|
||||
function UnplayableError () {
|
||||
this.message = 'Can\'t play any files in torrent'
|
||||
}
|
||||
UnplayableError.prototype = Error
|
||||
@@ -1,5 +0,0 @@
|
||||
var h = require('virtual-dom/h')
|
||||
var hyperx = require('hyperx')
|
||||
var hx = hyperx(h)
|
||||
|
||||
module.exports = hx
|
||||
@@ -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 () {}
|
||||
@@ -1,45 +0,0 @@
|
||||
module.exports = {
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio,
|
||||
isPlayableTorrent
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
|
||||
/**
|
||||
* Determines whether a file in a torrent is audio/video we can play
|
||||
*/
|
||||
function isPlayable (file) {
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
function isVideo (file) {
|
||||
var ext = path.extname(file.name).toLowerCase()
|
||||
return [
|
||||
'.avi',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpg',
|
||||
'.ogv',
|
||||
'.webm',
|
||||
'.wmv'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
function isAudio (file) {
|
||||
var ext = path.extname(file.name).toLowerCase()
|
||||
return [
|
||||
'.aac',
|
||||
'.ac3',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.wav'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
function isPlayableTorrent (torrentSummary) {
|
||||
return torrentSummary.files && torrentSummary.files.some(isPlayable)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
module.exports = {
|
||||
getPosterPath,
|
||||
getTorrentPath
|
||||
}
|
||||
|
||||
var path = require('path')
|
||||
var config = require('../../config')
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the torrent file, or null if unavailable
|
||||
function getTorrentPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.torrentFileName) return null
|
||||
return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName)
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the poster image, or null if unavailable
|
||||
function getPosterPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.posterFileName) return null
|
||||
var posterPath = path.join(config.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, '/')
|
||||
}
|
||||
1371
renderer/main.js
1371
renderer/main.js
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
module.exports = App
|
||||
|
||||
var hx = require('../lib/hx')
|
||||
var Header = require('./header')
|
||||
|
||||
var Views = {
|
||||
'home': require('./home'),
|
||||
'player': require('./player'),
|
||||
'create-torrent': require('./create-torrent'),
|
||||
'preferences': require('./preferences')
|
||||
}
|
||||
|
||||
var Modals = {
|
||||
'open-torrent-address-modal': require('./open-torrent-address-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)
|
||||
}
|
||||
@@ -1,159 +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=${handleToggleShowAdvanced}>
|
||||
${info.showAdvanced ? 'Basic' : 'Advanced'}
|
||||
</div>
|
||||
<div class="create-torrent-advanced ${collapsedClass}">
|
||||
<p class='torrent-attribute'>
|
||||
<label>Comment:</label>
|
||||
<textarea class='torrent-attribute torrent-comment'></textarea>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Trackers:</label>
|
||||
<textarea class='torrent-attribute torrent-trackers'>${trackers}</textarea>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Private:</label>
|
||||
<input type='checkbox' class='torrent-is-private' value='torrent-is-private'>
|
||||
</p>
|
||||
<p class='torrent-attribute'>
|
||||
<label>Files:</label>
|
||||
<div>${fileElems}</div>
|
||||
</p>
|
||||
</div>
|
||||
<p class="float-right">
|
||||
<button class='button-flat light' onclick=${handleCancel}>Cancel</button>
|
||||
<button class='button-raised' onclick=${handleOK}>Create Torrent</button>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
function handleOK () {
|
||||
var announceList = document.querySelector('.torrent-trackers').value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
var isPrivate = document.querySelector('.torrent-is-private').checked
|
||||
var comment = document.querySelector('.torrent-comment').value.trim()
|
||||
var options = {
|
||||
// We can't let the user choose their own name if we want WebTorrent
|
||||
// to use the files in place rather than creating a new folder.
|
||||
// If we ever want to add support for that:
|
||||
// name: document.querySelector('.torrent-name').value
|
||||
name: defaultName,
|
||||
path: basePath,
|
||||
files: files,
|
||||
announce: announceList,
|
||||
private: isPrivate,
|
||||
comment: comment
|
||||
}
|
||||
dispatch('createTorrent', options)
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
dispatch('back')
|
||||
}
|
||||
|
||||
function handleToggleShowAdvanced () {
|
||||
// TODO: what's the clean way to handle this?
|
||||
// Should every button on every screen have its own dispatch()?
|
||||
info.showAdvanced = !info.showAdvanced
|
||||
dispatch('update')
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.isPlayableTorrent(torrentSummary)) {
|
||||
playButton = hx`
|
||||
<i.button-round.icon.play
|
||||
title=${playTooltip}
|
||||
class=${playClass}
|
||||
onclick=${dispatcher('play', 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('deleteTorrent', infoHash)}>
|
||||
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('play', 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>
|
||||
`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,630 +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) {
|
||||
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
|
||||
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'> </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'},
|
||||
'dnla': {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) {
|
||||
dispatch('mediaMouseMoved')
|
||||
var windowWidth = document.querySelector('body').clientWidth
|
||||
var fraction = e.clientX / windowWidth
|
||||
var position = fraction * state.playing.duration /* seconds */
|
||||
dispatch('playbackJump', 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
8
src/.babelrc
Normal file
8
src/.babelrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
"syntax-jsx",
|
||||
"transform-es2015-destructuring",
|
||||
"transform-object-rest-spread",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
var appConfig = require('application-config')('WebTorrent')
|
||||
var fs = require('fs')
|
||||
var path = require('path')
|
||||
const appConfig = require('application-config')('WebTorrent')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
var APP_NAME = 'WebTorrent'
|
||||
var APP_TEAM = 'WebTorrent, LLC'
|
||||
var APP_VERSION = require('./package.json').version
|
||||
const APP_NAME = 'WebTorrent'
|
||||
const APP_TEAM = 'WebTorrent, LLC'
|
||||
const APP_VERSION = require('../package.json').version
|
||||
|
||||
var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings')
|
||||
const PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings')
|
||||
|
||||
module.exports = {
|
||||
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
|
||||
@@ -15,8 +15,8 @@ module.exports = {
|
||||
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
|
||||
|
||||
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
|
||||
APP_FILE_ICON: path.join(__dirname, 'static', 'WebTorrentFile'),
|
||||
APP_ICON: path.join(__dirname, 'static', 'WebTorrent'),
|
||||
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
|
||||
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
|
||||
APP_NAME: APP_NAME,
|
||||
APP_TEAM: APP_TEAM,
|
||||
APP_VERSION: APP_VERSION,
|
||||
@@ -66,13 +66,13 @@ module.exports = {
|
||||
IS_PRODUCTION: isProduction(),
|
||||
|
||||
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
|
||||
ROOT_PATH: __dirname,
|
||||
STATIC_PATH: path.join(__dirname, 'static'),
|
||||
ROOT_PATH: path.join(__dirname, '..'),
|
||||
STATIC_PATH: path.join(__dirname, '..', 'static'),
|
||||
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
|
||||
|
||||
WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'),
|
||||
WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html'),
|
||||
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'),
|
||||
WINDOW_ABOUT: 'file://' + path.join(__dirname, '..', 'static', 'about.html'),
|
||||
WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'),
|
||||
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'),
|
||||
|
||||
WINDOW_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents
|
||||
WINDOW_MIN_WIDTH: 425
|
||||
@@ -95,7 +95,7 @@ function getDefaultDownloadPath () {
|
||||
return path.join(getConfigPath(), 'Downloads')
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
const electron = require('electron')
|
||||
|
||||
return process.type === 'renderer'
|
||||
? electron.remote.app.getPath('downloads')
|
||||
@@ -2,8 +2,8 @@ module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var config = require('./config')
|
||||
var electron = require('electron')
|
||||
const config = require('./config')
|
||||
const electron = require('electron')
|
||||
|
||||
function init () {
|
||||
electron.crashReporter.start({
|
||||
@@ -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())
|
||||
@@ -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,7 +17,7 @@ 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' ]
|
||||
}
|
||||
@@ -38,7 +37,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' ]
|
||||
@@ -62,7 +61,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 +84,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 +108,7 @@ function openTorrentAddress () {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialogs on do not show a title on OS X, so the window title is used instead.
|
||||
* Dialogs on do not show a title on Mac, so the window title is used instead.
|
||||
*/
|
||||
function setTitle (title) {
|
||||
if (process.platform === 'darwin') {
|
||||
@@ -118,5 +117,5 @@ function setTitle (title) {
|
||||
}
|
||||
|
||||
function resetTitle () {
|
||||
setTitle(config.APP_WINDOW_TITLE)
|
||||
windows.main.dispatch('resetTitle')
|
||||
}
|
||||
@@ -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 () {
|
||||
71
src/main/external-player.js
Normal file
71
src/main/external-player.js
Normal file
@@ -0,0 +1,71 @@
|
||||
module.exports = {
|
||||
spawn,
|
||||
kill,
|
||||
checkInstall
|
||||
}
|
||||
|
||||
const cp = require('child_process')
|
||||
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 (path, cb) {
|
||||
// check for VLC if external player has not been specified by the user
|
||||
// otherwise assume the player is installed
|
||||
if (path == null) return vlcCommand((err) => cb(!err))
|
||||
process.nextTick(() => cb(true))
|
||||
}
|
||||
|
||||
function spawn (path, url, title) {
|
||||
if (path != null) return spawnExternal(path, [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 (path, args) {
|
||||
log('Running external media player:', path + ' ' + args.join(' '))
|
||||
|
||||
proc = cp.spawn(path, 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)
|
||||
})
|
||||
}
|
||||
@@ -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,30 +31,31 @@ 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')
|
||||
|
||||
// File handlers are defined in `Info.plist`.
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -63,6 +64,12 @@ function installWin32 () {
|
||||
iconPath,
|
||||
EXEC_COMMAND
|
||||
)
|
||||
registerProtocolHandlerWin32(
|
||||
'stream-magnet',
|
||||
'URL:BitTorrent Stream-Magnet URL',
|
||||
iconPath,
|
||||
EXEC_COMMAND
|
||||
)
|
||||
registerFileHandlerWin32(
|
||||
'.torrent',
|
||||
'io.webtorrent.torrent',
|
||||
@@ -93,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
|
||||
})
|
||||
@@ -113,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'
|
||||
})
|
||||
@@ -123,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'
|
||||
})
|
||||
@@ -154,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
|
||||
})
|
||||
@@ -164,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
|
||||
})
|
||||
@@ -174,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'
|
||||
})
|
||||
@@ -184,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'
|
||||
})
|
||||
@@ -198,16 +205,17 @@ function installWin32 () {
|
||||
}
|
||||
|
||||
function uninstallWin32 () {
|
||||
var Registry = require('winreg')
|
||||
const Registry = require('winreg')
|
||||
|
||||
unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND)
|
||||
unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND)
|
||||
unregisterFileHandlerWin32('.torrent', 'io.webtorrent.torrent', EXEC_COMMAND)
|
||||
|
||||
function unregisterProtocolHandlerWin32 (protocol, command) {
|
||||
getCommand()
|
||||
|
||||
function getCommand () {
|
||||
var commandKey = new Registry({
|
||||
const commandKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
|
||||
})
|
||||
@@ -219,7 +227,7 @@ function uninstallWin32 () {
|
||||
}
|
||||
|
||||
function destroyProtocol () {
|
||||
var protocolKey = new Registry({
|
||||
const protocolKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + protocol
|
||||
})
|
||||
@@ -231,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
|
||||
})
|
||||
@@ -239,7 +247,7 @@ function uninstallWin32 () {
|
||||
}
|
||||
|
||||
function getExt () {
|
||||
var extKey = new Registry({
|
||||
const extKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: '\\Software\\Classes\\' + ext
|
||||
})
|
||||
@@ -251,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
|
||||
})
|
||||
@@ -265,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-extra')
|
||||
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)
|
||||
@@ -285,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
|
||||
|
||||
@@ -294,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',
|
||||
@@ -308,14 +319,14 @@ 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 iconFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
@@ -330,11 +341,11 @@ function installLinux () {
|
||||
}
|
||||
|
||||
function uninstallLinux () {
|
||||
var os = require('os')
|
||||
var path = require('path')
|
||||
var fs = require('fs-extra')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
|
||||
var desktopFilePath = path.join(
|
||||
const desktopFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
@@ -343,7 +354,7 @@ function uninstallLinux () {
|
||||
)
|
||||
fs.removeSync(desktopFilePath)
|
||||
|
||||
var iconFilePath = path.join(
|
||||
const iconFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.local',
|
||||
'share',
|
||||
@@ -1,35 +1,41 @@
|
||||
console.time('init')
|
||||
|
||||
var electron = require('electron')
|
||||
const electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
var ipcMain = electron.ipcMain
|
||||
const app = electron.app
|
||||
const 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')
|
||||
const announcement = require('./announcement')
|
||||
const config = require('../config')
|
||||
const crashReporter = require('../crash-reporter')
|
||||
const dialog = require('./dialog')
|
||||
const dock = require('./dock')
|
||||
const ipc = require('./ipc')
|
||||
const log = require('./log')
|
||||
const menu = require('./menu')
|
||||
const squirrelWin32 = require('./squirrel-win32')
|
||||
const tray = require('./tray')
|
||||
const updater = require('./updater')
|
||||
const userTasks = require('./user-tasks')
|
||||
const windows = require('./windows')
|
||||
|
||||
var shouldQuit = false
|
||||
var argv = sliceArgv(process.argv)
|
||||
let shouldQuit = false
|
||||
let argv = sliceArgv(process.argv)
|
||||
|
||||
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') {
|
||||
shouldQuit = squirrelWin32.handleEvent(argv[0])
|
||||
argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1)
|
||||
argv = argv.filter((arg) => !arg.includes('--squirrel'))
|
||||
}
|
||||
|
||||
if (!shouldQuit) {
|
||||
// Prevent multiple instances of app from running at same time. New instances signal
|
||||
// this instance and quit.
|
||||
// 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()
|
||||
@@ -45,7 +51,7 @@ function init () {
|
||||
app.setPath('userData', config.CONFIG_PATH)
|
||||
}
|
||||
|
||||
var isReady = false // app ready, windows can be created
|
||||
let isReady = false // app ready, windows can be created
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
@@ -72,8 +78,8 @@ function 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)
|
||||
const error = {message: err.message, stack: err.stack}
|
||||
windows.main.dispatch('uncaughtError', 'main', error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +96,10 @@ function init () {
|
||||
e.preventDefault()
|
||||
windows.main.dispatch('saveState') // try to save state on exit
|
||||
ipcMain.once('savedState', () => app.quit())
|
||||
setTimeout(() => app.quit(), 2000) // quit after 2 secs, at most
|
||||
setTimeout(() => {
|
||||
console.error('Saving state took too long. Quitting.')
|
||||
app.quit()
|
||||
}, 2000) // quit after 2 secs, at most
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
@@ -101,9 +110,9 @@ function init () {
|
||||
function delayedInit () {
|
||||
announcement.init()
|
||||
dock.init()
|
||||
handlers.install()
|
||||
tray.init()
|
||||
updater.init()
|
||||
userTasks.init()
|
||||
}
|
||||
|
||||
function onOpen (e, torrentId) {
|
||||
@@ -139,7 +148,7 @@ function sliceArgv (argv) {
|
||||
}
|
||||
|
||||
function processArgv (argv) {
|
||||
var torrentIds = []
|
||||
let torrentIds = []
|
||||
argv.forEach(function (arg) {
|
||||
if (arg === '-n') {
|
||||
dialog.openSeedDirectory()
|
||||
@@ -148,9 +157,12 @@ function processArgv (argv) {
|
||||
} else if (arg === '-u') {
|
||||
dialog.openTorrentAddress()
|
||||
} else if (arg.startsWith('-psn')) {
|
||||
// Ignore OS X launchd "process serial number" argument
|
||||
// Ignore Mac launchd "process serial number" argument
|
||||
// Issue: https://github.com/feross/webtorrent-desktop/issues/214
|
||||
} else {
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
@@ -2,29 +2,27 @@ module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
const electron = require('electron')
|
||||
|
||||
var app = electron.app
|
||||
const 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 thumbnail = require('./thumbnail')
|
||||
const dialog = require('./dialog')
|
||||
const dock = require('./dock')
|
||||
const handlers = require('./handlers')
|
||||
const log = require('./log')
|
||||
const menu = require('./menu')
|
||||
const powerSaveBlocker = require('./power-save-blocker')
|
||||
const shell = require('./shell')
|
||||
const shortcuts = require('./shortcuts')
|
||||
const externalPlayer = require('./external-player')
|
||||
const windows = require('./windows')
|
||||
const 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
|
||||
const messageQueueMainToWebTorrent = []
|
||||
|
||||
function init () {
|
||||
var ipc = electron.ipcMain
|
||||
const ipc = electron.ipcMain
|
||||
|
||||
ipc.once('ipcReady', function (e) {
|
||||
app.ipcReady = true
|
||||
@@ -60,25 +58,33 @@ function init () {
|
||||
*/
|
||||
|
||||
ipc.on('onPlayerOpen', function () {
|
||||
menu.onPlayerOpen()
|
||||
shortcuts.onPlayerOpen()
|
||||
menu.setPlayerOpen(true)
|
||||
powerSaveBlocker.enable()
|
||||
shortcuts.enable()
|
||||
thumbar.enable()
|
||||
})
|
||||
|
||||
ipc.on('onPlayerUpdate', function (e, ...args) {
|
||||
menu.onPlayerUpdate(...args)
|
||||
thumbar.onPlayerUpdate(...args)
|
||||
})
|
||||
|
||||
ipc.on('onPlayerClose', function () {
|
||||
menu.onPlayerClose()
|
||||
shortcuts.onPlayerOpen()
|
||||
menu.setPlayerOpen(false)
|
||||
powerSaveBlocker.disable()
|
||||
shortcuts.disable()
|
||||
thumbar.disable()
|
||||
})
|
||||
|
||||
ipc.on('updateThumbnailBar', function (e, isPaused) {
|
||||
thumbnail.updateThumbarButtons(isPaused)
|
||||
ipc.on('onPlayerPlay', function () {
|
||||
powerSaveBlocker.enable()
|
||||
thumbar.onPlayerPlay()
|
||||
})
|
||||
|
||||
/**
|
||||
* Power Save Blocker
|
||||
*/
|
||||
|
||||
ipc.on('blockPowerSave', () => powerSaveBlocker.start())
|
||||
ipc.on('unblockPowerSave', () => powerSaveBlocker.stop())
|
||||
ipc.on('onPlayerPause', function () {
|
||||
powerSaveBlocker.disable()
|
||||
thumbar.onPlayerPause()
|
||||
})
|
||||
|
||||
/**
|
||||
* Shell
|
||||
@@ -88,11 +94,19 @@ function init () {
|
||||
ipc.on('showItemInFolder', (e, ...args) => shell.showItemInFolder(...args))
|
||||
ipc.on('moveItemToTrash', (e, ...args) => shell.moveItemToTrash(...args))
|
||||
|
||||
/**
|
||||
* File handlers
|
||||
*/
|
||||
ipc.on('setDefaultFileHandler', (e, flag) => {
|
||||
if (flag) handlers.install()
|
||||
else handlers.uninstall()
|
||||
})
|
||||
|
||||
/**
|
||||
* Windows: Main
|
||||
*/
|
||||
|
||||
var main = windows.main
|
||||
const main = windows.main
|
||||
|
||||
ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
|
||||
ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
|
||||
@@ -100,57 +114,23 @@ function init () {
|
||||
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))
|
||||
|
||||
/**
|
||||
* VLC
|
||||
* TODO: Move most of this code to vlc.js
|
||||
* External Media Player
|
||||
*/
|
||||
|
||||
ipc.on('checkForVLC', function (e) {
|
||||
vlc.checkForVLC(function (isInstalled) {
|
||||
windows.main.send('checkForVLC', isInstalled)
|
||||
ipc.on('checkForExternalPlayer', function (e, path) {
|
||||
externalPlayer.checkInstall(path, function (isInstalled) {
|
||||
windows.main.send('checkForExternalPlayer', 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
|
||||
})
|
||||
ipc.on('openExternalPlayer', (e, ...args) => externalPlayer.spawn(...args))
|
||||
ipc.on('quitExternalPlayer', () => externalPlayer.kill())
|
||||
|
||||
// Capture all events
|
||||
var oldEmit = ipc.emit
|
||||
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) {
|
||||
@@ -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) {
|
||||
@@ -1,54 +1,66 @@
|
||||
module.exports = {
|
||||
init,
|
||||
onPlayerClose,
|
||||
onPlayerOpen,
|
||||
setPlayerOpen,
|
||||
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')
|
||||
var thumbnail = require('./thumbnail')
|
||||
const config = require('../config')
|
||||
const dialog = require('./dialog')
|
||||
const shell = require('./shell')
|
||||
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 setPlayerOpen (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
|
||||
|
||||
thumbnail.showPlayerThumbnailBar()
|
||||
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
|
||||
}
|
||||
|
||||
thumbnail.hidePlayerThumbnailBar()
|
||||
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) {
|
||||
@@ -59,19 +71,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
|
||||
@@ -79,7 +81,7 @@ function getMenuItem (label) {
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
var template = [
|
||||
const template = [
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
@@ -104,10 +106,6 @@ function getMenuTemplate () {
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: process.platform === 'win32'
|
||||
? 'Close'
|
||||
: 'Close Window',
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
role: 'close'
|
||||
}
|
||||
]
|
||||
@@ -116,32 +114,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'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -205,6 +200,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',
|
||||
@@ -225,7 +235,7 @@ function getMenuTemplate () {
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Right'
|
||||
: 'Alt+Right',
|
||||
click: () => windows.main.dispatch('skip', 1),
|
||||
click: () => windows.main.dispatch('skip', 10),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
@@ -233,7 +243,7 @@ function getMenuTemplate () {
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Left'
|
||||
: 'Alt+Left',
|
||||
click: () => windows.main.dispatch('skip', -1),
|
||||
click: () => windows.main.dispatch('skip', -10),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
@@ -285,12 +295,11 @@ function getMenuTemplate () {
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// Add WebTorrent app menu (OS X)
|
||||
// Add WebTorrent app menu (Mac)
|
||||
template.unshift({
|
||||
label: config.APP_NAME,
|
||||
submenu: [
|
||||
{
|
||||
label: 'About ' + config.APP_NAME,
|
||||
role: 'about'
|
||||
},
|
||||
{
|
||||
@@ -305,7 +314,6 @@ function getMenuTemplate () {
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
role: 'services',
|
||||
submenu: []
|
||||
},
|
||||
@@ -313,45 +321,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'
|
||||
}
|
||||
]
|
||||
@@ -367,6 +364,17 @@ function getMenuTemplate () {
|
||||
click: () => 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(
|
||||
{
|
||||
34
src/main/power-save-blocker.js
Normal file
34
src/main/power-save-blocker.js
Normal file
@@ -0,0 +1,34 @@
|
||||
module.exports = {
|
||||
enable,
|
||||
disable
|
||||
}
|
||||
|
||||
const electron = require('electron')
|
||||
const log = require('./log')
|
||||
|
||||
let blockId = 0
|
||||
|
||||
/**
|
||||
* Block the system from entering low-power (sleep) mode or turning off the
|
||||
* display.
|
||||
*/
|
||||
function enable () {
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop blocking the system from entering low-power mode.
|
||||
*/
|
||||
function disable () {
|
||||
if (!electron.powerSaveBlocker.isStarted(blockId)) {
|
||||
// If a power saver block does not exist, do nothing.
|
||||
return
|
||||
}
|
||||
electron.powerSaveBlocker.stop(blockId)
|
||||
log(`powerSaveBlocker.disable: ${blockId}`)
|
||||
}
|
||||
@@ -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 desktop’s default manner.
|
||||
30
src/main/shortcuts.js
Normal file
30
src/main/shortcuts.js
Normal 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')
|
||||
}
|
||||
@@ -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()
|
||||
91
src/main/thumbar.js
Normal file
91
src/main/thumbar.js
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ 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
|
||||
|
||||
@@ -63,7 +63,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
43
src/main/user-tasks.js
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1,4 +1,4 @@
|
||||
var main = module.exports = {
|
||||
const main = module.exports = {
|
||||
dispatch,
|
||||
hide,
|
||||
init,
|
||||
@@ -14,30 +14,30 @@ var main = module.exports = {
|
||||
win: null
|
||||
}
|
||||
|
||||
var electron = require('electron')
|
||||
const electron = require('electron')
|
||||
|
||||
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')
|
||||
const tray = require('../tray')
|
||||
|
||||
var HEADER_HEIGHT = 37
|
||||
var TORRENT_HEIGHT = 100
|
||||
const HEADER_HEIGHT = 38
|
||||
const TORRENT_HEIGHT = 100
|
||||
|
||||
function init () {
|
||||
if (main.win) {
|
||||
return main.win.show()
|
||||
}
|
||||
var win = main.win = new electron.BrowserWindow({
|
||||
backgroundColor: '#1E1E1E',
|
||||
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
|
||||
@@ -95,7 +95,7 @@ function send (...args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce window aspect ratio. Remove with 0. (OS X)
|
||||
* Enforce window aspect ratio. Remove with 0. (Mac)
|
||||
*/
|
||||
function setAspectRatio (aspectRatio) {
|
||||
if (!main.win) return
|
||||
@@ -114,7 +114,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 +136,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 +202,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 +210,13 @@ function toggleFullScreen (flag) {
|
||||
}
|
||||
|
||||
function onWindowBlur () {
|
||||
menu.onWindowBlur()
|
||||
tray.onWindowBlur()
|
||||
menu.setWindowFocus(false)
|
||||
tray.setWindowFocus(false)
|
||||
}
|
||||
|
||||
function onWindowFocus () {
|
||||
menu.onWindowFocus()
|
||||
tray.onWindowFocus()
|
||||
menu.setWindowFocus(true)
|
||||
tray.setWindowFocus(true)
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
@@ -1,4 +1,4 @@
|
||||
var webtorrent = module.exports = {
|
||||
const webtorrent = module.exports = {
|
||||
init,
|
||||
send,
|
||||
show,
|
||||
@@ -6,13 +6,13 @@ 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')
|
||||
const log = require('../log')
|
||||
|
||||
function init () {
|
||||
var win = webtorrent.win = new electron.BrowserWindow({
|
||||
const win = webtorrent.win = new electron.BrowserWindow({
|
||||
backgroundColor: '#1E1E1E',
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
26
src/renderer/components/create-torrent-error-page.js
Normal file
26
src/renderer/components/create-torrent-error-page.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
52
src/renderer/components/header.js
Normal file
52
src/renderer/components/header.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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'>
|
||||
{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
|
||||
34
src/renderer/components/heading.js
Normal file
34
src/renderer/components/heading.js
Normal 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
|
||||
24
src/renderer/components/modal-ok-cancel.js
Normal file
24
src/renderer/components/modal-ok-cancel.js
Normal 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'
|
||||
style={cancelStyle}
|
||||
label={cancelText}
|
||||
onClick={onCancel} />
|
||||
<RaisedButton
|
||||
className='control'
|
||||
primary
|
||||
label={okText}
|
||||
onClick={onOK} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/renderer/components/open-torrent-address-modal.js
Normal file
40
src/renderer/components/open-torrent-address-modal.js
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
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) this.handleOK() /* hit Enter to submit */
|
||||
}
|
||||
|
||||
function handleOK () {
|
||||
dispatch('exitModal')
|
||||
dispatch('addTorrent', this.torrentURL.input.value)
|
||||
}
|
||||
86
src/renderer/components/path-selector.js
Normal file
86
src/renderer/components/path-selector.js
Normal 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
|
||||
30
src/renderer/components/remove-torrent-modal.js
Normal file
30
src/renderer/components/remove-torrent-modal.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/renderer/components/show-more.js
Normal file
52
src/renderer/components/show-more.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const React = require('react')
|
||||
|
||||
const FlatButton = require('material-ui/FlatButton').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 style={this.props.style}>
|
||||
{this.state.expanded ? this.props.children : null}
|
||||
<FlatButton
|
||||
onClick={this.handleClick}
|
||||
label={label} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShowMore
|
||||
49
src/renderer/components/unsupported-media-modal.js
Normal file
49
src/renderer/components/unsupported-media-modal.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const React = require('react')
|
||||
const electron = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
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 playerPath = state.saved.prefs.externalPlayerPath
|
||||
const playerName = playerPath
|
||||
? path.basename(playerPath).split('.')[0]
|
||||
: 'VLC'
|
||||
const onAction = state.modal.externalPlayerInstalled
|
||||
? dispatcher('openExternalPlayer')
|
||||
: () => this.onInstall()
|
||||
const actionText = state.modal.externalPlayerInstalled
|
||||
? 'PLAY IN ' + playerName.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
|
||||
}
|
||||
}
|
||||
35
src/renderer/components/update-available-modal.js
Normal file
35
src/renderer/components/update-available-modal.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/renderer/controllers/media-controller.js
Normal file
63
src/renderer/controllers/media-controller.js
Normal file
@@ -0,0 +1,63 @@
|
||||
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()
|
||||
}
|
||||
|
||||
openExternalPlayer () {
|
||||
const state = this.state
|
||||
const mediaURL = Playlist.getCurrentLocalURL(this.state)
|
||||
ipcRenderer.send('openExternalPlayer',
|
||||
state.saved.prefs.externalPlayerPath,
|
||||
mediaURL,
|
||||
state.window.title)
|
||||
state.playing.location = 'external'
|
||||
}
|
||||
|
||||
externalPlayerNotFound () {
|
||||
const modal = this.state.modal
|
||||
if (modal && modal.id === 'unsupported-media-modal') {
|
||||
modal.externalPlayerNotFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
388
src/renderer/controllers/playback-controller.js
Normal file
388
src/renderer/controllers/playback-controller.js
Normal file
@@ -0,0 +1,388 @@
|
||||
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 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 errors.UnplayableError())
|
||||
|
||||
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)) {
|
||||
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)) {
|
||||
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 >= 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))
|
||||
|
||||
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.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
|
||||
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
|
||||
if (torrentSummary.progress !== 1) sound.play('PLAY')
|
||||
// TODO: remove torrentSummary.playStatus
|
||||
torrentSummary.playStatus = 'requested'
|
||||
this.update()
|
||||
|
||||
const 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 */
|
||||
|
||||
this.startServer(torrentSummary, () => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
// if we timed out (user clicked play a long time ago), don't autoplay
|
||||
const timedOut = torrentSummary.playStatus === 'timeout'
|
||||
delete torrentSummary.playStatus
|
||||
if (timedOut) {
|
||||
ipcRenderer.send('wt-stop-server')
|
||||
return this.update()
|
||||
}
|
||||
|
||||
ipcRenderer.send('onPlayerOpen')
|
||||
this.updatePlayer(infoHash, index, true, cb)
|
||||
})
|
||||
}
|
||||
|
||||
// Starts WebTorrent server for media streaming
|
||||
startServer (torrentSummary, cb) {
|
||||
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)
|
||||
ipcRenderer.once('wt-server-' + torrentSummary.infoHash, () => cb())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 Error('Can\'t play that file'))
|
||||
}
|
||||
|
||||
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 (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)
|
||||
}
|
||||
}
|
||||
57
src/renderer/controllers/prefs-controller.js
Normal file
57
src/renderer/controllers/prefs-controller.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const State = require('../lib/state')
|
||||
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: 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)
|
||||
}
|
||||
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
|
||||
State.save(state)
|
||||
dispatch('checkDownloadPath')
|
||||
}
|
||||
}
|
||||
139
src/renderer/controllers/subtitles-controller.js
Normal file
139
src/renderer/controllers/subtitles-controller.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const electron = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const parallel = require('run-parallel')
|
||||
|
||||
const remote = electron.remote
|
||||
|
||||
const {dispatch} = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class SubtitlesController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
openSubtitles () {
|
||||
remote.dialog.showOpenDialog({
|
||||
title: 'Select a subtitles file.',
|
||||
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
|
||||
properties: [ 'openFile' ]
|
||||
}, (filenames) => {
|
||||
if (!Array.isArray(filenames)) return
|
||||
this.addSubtitles(filenames, true)
|
||||
})
|
||||
}
|
||||
|
||||
selectSubtitle (ix) {
|
||||
this.state.playing.subtitles.selectedIndex = ix
|
||||
}
|
||||
|
||||
toggleSubtitlesMenu () {
|
||||
const subtitles = this.state.playing.subtitles
|
||||
subtitles.showMenu = !subtitles.showMenu
|
||||
}
|
||||
|
||||
addSubtitles (files, autoSelect) {
|
||||
const state = this.state
|
||||
// Subtitles are only supported when playing video files
|
||||
if (state.playing.type !== 'video') return
|
||||
if (files.length === 0) return
|
||||
const subtitles = state.playing.subtitles
|
||||
|
||||
// Read the files concurrently, then add all resulting subtitle tracks
|
||||
const tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
|
||||
parallel(tasks, function (err, tracks) {
|
||||
if (err) return dispatch('error', err)
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
// No dupes allowed
|
||||
const track = tracks[i]
|
||||
let trackIndex = state.playing.subtitles.tracks
|
||||
.findIndex((t) => track.filePath === t.filePath)
|
||||
|
||||
// Add the track
|
||||
if (trackIndex === -1) {
|
||||
trackIndex = state.playing.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
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, make sure no two tracks have the same label
|
||||
relabelSubtitles(subtitles)
|
||||
})
|
||||
}
|
||||
|
||||
checkForSubtitles () {
|
||||
if (this.state.playing.type !== 'video') return
|
||||
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
|
||||
const file = torrentSummary.files[ix]
|
||||
if (!this.isSubtitle(file.name)) return
|
||||
const filePath = path.join(torrentSummary.path, file.path)
|
||||
this.addSubtitles([filePath], false)
|
||||
})
|
||||
}
|
||||
|
||||
isSubtitle (file) {
|
||||
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
|
||||
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
|
||||
const filePath = file.path || file
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
const track = {
|
||||
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
|
||||
language: langDetected,
|
||||
label: langDetected,
|
||||
filePath: filePath
|
||||
}
|
||||
|
||||
cb(null, track)
|
||||
})
|
||||
}
|
||||
|
||||
// Checks whether a language name like 'English' or 'German' matches the system
|
||||
// language, aka the current locale
|
||||
function isSystemLanguage (language) {
|
||||
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', ...
|
||||
function relabelSubtitles (subtitles) {
|
||||
const counts = {}
|
||||
subtitles.tracks.forEach(function (track) {
|
||||
const lang = track.language
|
||||
counts[lang] = (counts[lang] || 0) + 1
|
||||
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang
|
||||
})
|
||||
}
|
||||
185
src/renderer/controllers/torrent-controller.js
Normal file
185
src/renderer/controllers/torrent-controller.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const path = require('path')
|
||||
const ipcRenderer = require('electron').ipcRenderer
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const sound = require('../lib/sound')
|
||||
const {dispatch} = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class TorrentController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
torrentInfoHash (torrentKey, infoHash) {
|
||||
let torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
console.log('got infohash for %s torrent %s',
|
||||
torrentSummary ? 'existing' : 'new', torrentKey)
|
||||
|
||||
if (!torrentSummary) {
|
||||
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)) {
|
||||
ipcRenderer.send('wt-stop-torrenting', infoHash)
|
||||
return dispatch('error', 'Cannot add duplicate torrent')
|
||||
}
|
||||
|
||||
torrentSummary = {
|
||||
torrentKey: torrentKey,
|
||||
status: 'new'
|
||||
}
|
||||
torrents.unshift(torrentSummary)
|
||||
sound.play('ADD')
|
||||
}
|
||||
|
||||
torrentSummary.infoHash = infoHash
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentWarning (torrentKey, message) {
|
||||
console.log('warning for torrent %s: %s', torrentKey, message)
|
||||
}
|
||||
|
||||
torrentError (torrentKey, message) {
|
||||
// TODO: WebTorrent needs semantic errors
|
||||
if (message.startsWith('Cannot add duplicate torrent')) {
|
||||
// Remove infohash from the message
|
||||
message = 'Cannot add duplicate torrent'
|
||||
}
|
||||
dispatch('error', message)
|
||||
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
if (torrentSummary) {
|
||||
console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message)
|
||||
torrentSummary.status = 'paused'
|
||||
dispatch('update')
|
||||
}
|
||||
}
|
||||
|
||||
torrentMetadata (torrentKey, torrentInfo) {
|
||||
// Summarize torrent
|
||||
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:
|
||||
const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
|
||||
if (!hasDetailedFileInfo) {
|
||||
torrentSummary.files = torrentInfo.files
|
||||
}
|
||||
if (!torrentSummary.selections) {
|
||||
torrentSummary.selections = torrentSummary.files.map((x) => true)
|
||||
}
|
||||
dispatch('update')
|
||||
|
||||
// Save the .torrent file, if it hasn't been saved already
|
||||
if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey)
|
||||
|
||||
// Auto-generate a poster image, if it hasn't been generated already
|
||||
if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey)
|
||||
}
|
||||
|
||||
torrentDone (torrentKey, torrentInfo) {
|
||||
// Update the torrent summary
|
||||
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.
|
||||
// Don't notify if we merely finished verifying data files that were already on disk.
|
||||
if (torrentInfo.bytesReceived > 0) {
|
||||
if (!this.state.window.isFocused) {
|
||||
this.state.dock.badge += 1
|
||||
}
|
||||
showDoneNotification(torrentSummary)
|
||||
ipcRenderer.send('downloadFinished', getTorrentPath(torrentSummary))
|
||||
}
|
||||
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentProgress (progressInfo) {
|
||||
// Overall progress across all active torrents, 0 to 1, or -1 to hide the progress bar
|
||||
// Hide progress bar when client has no torrents, or progress is 100%
|
||||
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) => {
|
||||
const torrentSummary = this.getTorrentSummary(p.torrentKey)
|
||||
if (!torrentSummary) {
|
||||
console.log('warning: got progress for missing torrent %s', p.torrentKey)
|
||||
return
|
||||
}
|
||||
torrentSummary.progress = p
|
||||
})
|
||||
|
||||
// TODO: Find an efficient way to re-enable this line, which allows subtitle
|
||||
// files which are completed after a video starts to play to be added
|
||||
// dynamically to the list of subtitles.
|
||||
// checkForSubtitles()
|
||||
}
|
||||
|
||||
torrentFileModtimes (torrentKey, fileModtimes) {
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.fileModtimes = fileModtimes
|
||||
dispatch('saveStateThrottled')
|
||||
}
|
||||
|
||||
torrentFileSaved (torrentKey, torrentFileName) {
|
||||
console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.torrentFileName = torrentFileName
|
||||
dispatch('saveStateThrottled')
|
||||
}
|
||||
|
||||
torrentPosterSaved (torrentKey, posterFileName) {
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.posterFileName = posterFileName
|
||||
dispatch('saveStateThrottled')
|
||||
}
|
||||
|
||||
torrentAudioMetadata (infoHash, index, info) {
|
||||
const torrentSummary = this.getTorrentSummary(infoHash)
|
||||
const fileSummary = torrentSummary.files[index]
|
||||
fileSummary.audioInfo = info
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentServerRunning (serverInfo) {
|
||||
this.state.server = serverInfo
|
||||
}
|
||||
|
||||
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
||||
// Returns undefined if we don't know that infoHash
|
||||
getTorrentSummary (torrentKey) {
|
||||
return TorrentSummary.getByKey(this.state, torrentKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getTorrentPath (torrentSummary) {
|
||||
let itemPath = TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
if (torrentSummary.files.length > 1) {
|
||||
itemPath = path.dirname(itemPath)
|
||||
}
|
||||
return itemPath
|
||||
}
|
||||
|
||||
function showDoneNotification (torrent) {
|
||||
const notif = new window.Notification('Download Complete', {
|
||||
body: torrent.name,
|
||||
silent: true
|
||||
})
|
||||
|
||||
notif.onClick = function () {
|
||||
ipcRenderer.send('show')
|
||||
}
|
||||
|
||||
// Only play notification sound if player is inactive
|
||||
if (this.state.playing.isPaused) sound.play('DONE')
|
||||
}
|
||||
303
src/renderer/controllers/torrent-list-controller.js
Normal file
303
src/renderer/controllers/torrent-list-controller.js
Normal file
@@ -0,0 +1,303 @@
|
||||
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://git.io/vik9M
|
||||
addTorrent (torrentId) {
|
||||
if (torrentId.path) {
|
||||
// Use path string instead of W3C File object
|
||||
torrentId = torrentId.path
|
||||
}
|
||||
|
||||
// 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 Error('Missing key: ' + 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'
|
||||
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
|
||||
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)
|
||||
State.saveThrottled(this.state)
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
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))
|
||||
}
|
||||
|
||||
function saveTorrentFileAs (torrentSummary) {
|
||||
const downloadPath = this.state.saved.prefs.downloadPath
|
||||
const newFileName = path.parse(torrentSummary.name).name + '.torrent'
|
||||
const opts = {
|
||||
title: 'Save Torrent File',
|
||||
defaultPath: path.join(downloadPath, newFileName),
|
||||
filters: [
|
||||
{ name: 'Torrent Files', extensions: ['torrent'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
}
|
||||
const win = electron.remote.getCurrentWindow()
|
||||
electron.remote.dialog.showSaveDialog(win, opts, function (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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
26
src/renderer/controllers/update-controller.js
Normal file
26
src/renderer/controllers/update-controller.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const State = require('../lib/state')
|
||||
|
||||
// Controls the UI checking for new versions of the app, prompting install
|
||||
module.exports = class UpdateController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
// Shows a modal saying that we have an update
|
||||
updateAvailable (version) {
|
||||
const skipped = this.state.saved.skippedVersions
|
||||
if (skipped && skipped.includes(version)) {
|
||||
console.log('new version skipped by user: v' + version)
|
||||
return
|
||||
}
|
||||
this.state.modal = { id: 'update-available-modal', version: version }
|
||||
}
|
||||
|
||||
// Don't show the modal again until the next version
|
||||
skipVersion (version) {
|
||||
let skipped = this.state.saved.skippedVersions
|
||||
if (!skipped) skipped = this.state.saved.skippedVersions = []
|
||||
skipped.push(version)
|
||||
State.saveThrottled(this.state)
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,14 @@ function captureVideoFrame (video, format) {
|
||||
throw new Error('Second argument must be one of "png", "jpg", or "webp"')
|
||||
}
|
||||
|
||||
var canvas = document.createElement('canvas')
|
||||
const 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]
|
||||
const dataUri = canvas.toDataURL('image/' + format)
|
||||
const data = dataUri.split(',')[1]
|
||||
|
||||
return new Buffer(data, 'base64')
|
||||
}
|
||||
@@ -13,26 +13,30 @@ module.exports = {
|
||||
setRate
|
||||
}
|
||||
|
||||
var airplayer = require('airplayer')()
|
||||
var chromecasts = require('chromecasts')()
|
||||
var dlnacasts = require('dlnacasts')()
|
||||
const config = require('../../config')
|
||||
|
||||
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
|
||||
|
||||
// Load modules, scan the network for devices
|
||||
airplayer = require('airplayer')()
|
||||
chromecasts = require('chromecasts')()
|
||||
dlnacasts = require('dlnacasts')()
|
||||
|
||||
state.devices.chromecast = chromecastPlayer()
|
||||
state.devices.dlna = dlnaPlayer()
|
||||
state.devices.airplay = airplayPlayer()
|
||||
@@ -55,7 +59,7 @@ function init (appState, callback) {
|
||||
|
||||
// chromecast player implementation
|
||||
function chromecastPlayer () {
|
||||
var ret = {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
@@ -91,8 +95,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) {
|
||||
@@ -142,7 +146,7 @@ function chromecastPlayer () {
|
||||
|
||||
// airplay player implementation
|
||||
function airplayPlayer () {
|
||||
var ret = {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
@@ -179,7 +183,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({
|
||||
@@ -234,7 +238,7 @@ function airplayPlayer () {
|
||||
|
||||
// DLNA player implementation
|
||||
function dlnaPlayer (player) {
|
||||
var ret = {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
@@ -270,8 +274,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
|
||||
@@ -327,7 +331,7 @@ function dlnaPlayer (player) {
|
||||
// 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)
|
||||
}
|
||||
@@ -346,12 +350,13 @@ 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 Error('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() : []
|
||||
const player = getPlayer(location)
|
||||
const devices = player ? player.getDevices() : []
|
||||
if (devices.length === 0) throw new Error('No ' + location + ' devices available')
|
||||
|
||||
// Show a menu
|
||||
@@ -359,10 +364,10 @@ function toggleMenu (location) {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -378,7 +383,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
|
||||
@@ -392,7 +397,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()
|
||||
}
|
||||
|
||||
@@ -411,18 +418,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()
|
||||
@@ -437,12 +444,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
15
src/renderer/lib/errors.js
Normal file
15
src/renderer/lib/errors.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
UnplayableTorrentError,
|
||||
UnplayableFileError
|
||||
}
|
||||
|
||||
function UnplayableTorrentError () {
|
||||
this.message = 'Can\'t play any files in torrent'
|
||||
}
|
||||
|
||||
function UnplayableFileError () {
|
||||
this.message = 'Can\'t play that file'
|
||||
}
|
||||
|
||||
UnplayableTorrentError.prototype = Error
|
||||
UnplayableFileError.prototype = Error
|
||||
@@ -4,18 +4,20 @@ module.exports = {
|
||||
run
|
||||
}
|
||||
|
||||
var semver = require('semver')
|
||||
var config = require('../../config')
|
||||
const semver = require('semver')
|
||||
const config = require('../../config')
|
||||
const TorrentSummary = require('./torrent-summary')
|
||||
const fs = require('fs')
|
||||
|
||||
// Change `state.saved` (which will be saved back to config.json on exit) as
|
||||
// needed, for example to deal with config.json format changes across versions
|
||||
function run (state) {
|
||||
// Replace "{ version: 1 }" with app version (semver)
|
||||
// Replace '{ version: 1 }' with app version (semver)
|
||||
if (!semver.valid(state.saved.version)) {
|
||||
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
|
||||
}
|
||||
|
||||
var version = state.saved.version
|
||||
const version = state.saved.version
|
||||
|
||||
if (semver.lt(version, '0.7.0')) {
|
||||
migrate_0_7_0(state.saved)
|
||||
@@ -25,18 +27,28 @@ function run (state) {
|
||||
migrate_0_7_2(state.saved)
|
||||
}
|
||||
|
||||
if (semver.lt(version, '0.11.0')) {
|
||||
migrate_0_11_0(state.saved)
|
||||
}
|
||||
|
||||
if (semver.lt(version, '0.12.0')) {
|
||||
migrate_0_12_0(state.saved)
|
||||
}
|
||||
|
||||
if (semver.lt(version, '0.14.0')) {
|
||||
migrate_0_14_0(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')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
saved.torrents.forEach(function (ts) {
|
||||
var infoHash = ts.infoHash
|
||||
const infoHash = ts.infoHash
|
||||
|
||||
// Replace torrentPath with torrentFileName
|
||||
// There are a number of cases to handle here:
|
||||
@@ -44,9 +56,8 @@ function migrate_0_7_0 (saved) {
|
||||
// * 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
|
||||
let src, dst
|
||||
if (ts.torrentPath) {
|
||||
console.log('replacing torrentPath %s', ts.torrentPath)
|
||||
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
|
||||
src = ts.torrentPath
|
||||
} else {
|
||||
@@ -63,8 +74,7 @@ function migrate_0_7_0 (saved) {
|
||||
|
||||
// Replace posterURL with posterFileName
|
||||
if (ts.posterURL) {
|
||||
console.log('replacing posterURL %s', ts.posterURL)
|
||||
var extension = path.extname(ts.posterURL)
|
||||
const extension = path.extname(ts.posterURL)
|
||||
src = path.isAbsolute(ts.posterURL)
|
||||
? ts.posterURL
|
||||
: path.join(config.STATIC_PATH, ts.posterURL)
|
||||
@@ -87,9 +97,51 @@ function migrate_0_7_0 (saved) {
|
||||
}
|
||||
|
||||
function migrate_0_7_2 (saved) {
|
||||
if (!saved.prefs) {
|
||||
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) {
|
||||
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 (e) {
|
||||
// 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
|
||||
})
|
||||
}
|
||||
85
src/renderer/lib/playlist.js
Normal file
85
src/renderer/lib/playlist.js
Normal file
@@ -0,0 +1,85 @@
|
||||
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.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 (var 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 (var i = state.playing.fileIndex + 1; i < files.length; i++) {
|
||||
if (TorrentPlayer.isPlayable(files[i])) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -3,15 +3,15 @@ module.exports = {
|
||||
play
|
||||
}
|
||||
|
||||
var config = require('../../config')
|
||||
var path = require('path')
|
||||
const config = require('../../config')
|
||||
const path = require('path')
|
||||
|
||||
var VOLUME = 0.15
|
||||
const VOLUME = 0.15
|
||||
|
||||
/* 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
|
||||
@@ -47,10 +47,10 @@ var sounds = {
|
||||
}
|
||||
|
||||
function preload () {
|
||||
for (var name in sounds) {
|
||||
for (let name in sounds) {
|
||||
if (!cache[name]) {
|
||||
var sound = sounds[name]
|
||||
var audio = cache[name] = new window.Audio()
|
||||
const sound = sounds[name]
|
||||
const audio = cache[name] = new window.Audio()
|
||||
audio.volume = sound.volume
|
||||
audio.src = sound.url
|
||||
}
|
||||
@@ -58,9 +58,9 @@ function preload () {
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
module.exports = {
|
||||
const appConfig = require('application-config')('WebTorrent')
|
||||
const path = require('path')
|
||||
const {EventEmitter} = require('events')
|
||||
|
||||
const config = require('../../config')
|
||||
const migrations = require('./migrations')
|
||||
|
||||
const State = module.exports = Object.assign(new EventEmitter(), {
|
||||
getDefaultPlayState,
|
||||
load,
|
||||
save
|
||||
}
|
||||
|
||||
var appConfig = require('application-config')('WebTorrent')
|
||||
var path = require('path')
|
||||
|
||||
var config = require('../../config')
|
||||
var migrations = require('./migrations')
|
||||
save,
|
||||
saveThrottled
|
||||
})
|
||||
|
||||
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
|
||||
|
||||
function getDefaultState () {
|
||||
var LocationHistory = require('./location-history')
|
||||
const LocationHistory = require('location-history')
|
||||
|
||||
return {
|
||||
/*
|
||||
@@ -22,7 +24,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 } */
|
||||
@@ -48,7 +54,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
|
||||
@@ -80,6 +86,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 */
|
||||
@@ -91,22 +98,25 @@ 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')
|
||||
const fs = require('fs-extra')
|
||||
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
|
||||
},
|
||||
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(
|
||||
path.join(config.STATIC_PATH, t.posterFileName),
|
||||
@@ -129,8 +139,8 @@ function setupSavedState (cb) {
|
||||
})
|
||||
|
||||
function createTorrentObject (t) {
|
||||
var torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
|
||||
var parsedTorrent = parseTorrent(torrent)
|
||||
const torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
|
||||
const parsedTorrent = parseTorrent(torrent)
|
||||
|
||||
return {
|
||||
status: 'paused',
|
||||
@@ -147,18 +157,18 @@ function setupSavedState (cb) {
|
||||
}
|
||||
|
||||
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()
|
||||
const state = getDefaultState()
|
||||
|
||||
appConfig.read(function (err, saved) {
|
||||
if (err || !saved.version) {
|
||||
@@ -180,33 +190,42 @@ function load (cb) {
|
||||
// Write state.saved to the JSON state file
|
||||
function save (state, cb) {
|
||||
console.log('Saving state to ' + appConfig.filePath)
|
||||
|
||||
var electron = require('electron')
|
||||
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]
|
||||
}
|
||||
return torrent
|
||||
})
|
||||
|
||||
appConfig.write(copy, function (err) {
|
||||
appConfig.write(copy, (err) => {
|
||||
if (err) console.error(err)
|
||||
|
||||
// TODO: this doesn't belong here
|
||||
electron.ipcRenderer.send('savedState')
|
||||
else State.emit('savedState')
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const url = require('url')
|
||||
|
||||
const config = require('../../config')
|
||||
|
||||
var telemetry
|
||||
let telemetry
|
||||
|
||||
function init (state) {
|
||||
telemetry = state.saved.telemetry
|
||||
@@ -23,19 +23,29 @@ function init (state) {
|
||||
reset()
|
||||
}
|
||||
|
||||
var now = new Date()
|
||||
const now = new Date()
|
||||
telemetry.version = config.APP_VERSION
|
||||
telemetry.timestamp = now.toISOString()
|
||||
telemetry.localTime = now.toTimeString()
|
||||
telemetry.screens = getScreenInfo()
|
||||
telemetry.system = getSystemInfo()
|
||||
telemetry.approxNumTorrents = getApproxNumTorrents(state)
|
||||
|
||||
postToServer(telemetry)
|
||||
if (config.IS_PRODUCTION) {
|
||||
postToServer()
|
||||
// If the user keeps WebTorrent running for a long time, post every 12h
|
||||
setInterval(postToServer, 12 * 3600 * 1000)
|
||||
} 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 = {
|
||||
minVersion: config.APP_VERSION,
|
||||
total: 0,
|
||||
success: 0,
|
||||
timeout: 0,
|
||||
@@ -46,17 +56,17 @@ function reset () {
|
||||
|
||||
function postToServer () {
|
||||
// Serialize the telemetry summary
|
||||
var payload = new Buffer(JSON.stringify(telemetry), 'utf8')
|
||||
const payload = new Buffer(JSON.stringify(telemetry), 'utf8')
|
||||
|
||||
// POST to our server
|
||||
var options = url.parse(config.TELEMETRY_URL)
|
||||
const 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) {
|
||||
const req = https.request(options, function (res) {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('Successfully posted telemetry summary')
|
||||
reset()
|
||||
@@ -75,7 +85,7 @@ function postToServer () {
|
||||
// collects screen resolution, etc
|
||||
function createSummary () {
|
||||
// Make a 256-bit random unique ID
|
||||
var userID = crypto.randomBytes(32).toString('hex')
|
||||
const userID = crypto.randomBytes(32).toString('hex')
|
||||
return { userID }
|
||||
}
|
||||
|
||||
@@ -101,40 +111,83 @@ function getSystemInfo () {
|
||||
|
||||
// Get the number of torrents, rounded to the nearest power of two
|
||||
function getApproxNumTorrents (state) {
|
||||
var exactNum = state.saved.torrents.length
|
||||
const 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)
|
||||
const 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) {
|
||||
var message, stack
|
||||
if (typeof err === 'string') {
|
||||
message = err
|
||||
stack = ''
|
||||
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 {
|
||||
message = err.message
|
||||
stack = err.stack
|
||||
// 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)
|
||||
|
||||
telemetry.uncaughtErrors.push({process: procName, message, stack})
|
||||
// Log the app version *at the time of the error*
|
||||
const version = config.APP_VERSION
|
||||
|
||||
telemetry.uncaughtErrors.push({process: procName, message, stack, version})
|
||||
}
|
||||
|
||||
// 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 (e) {}
|
||||
return ret
|
||||
}
|
||||
|
||||
// The user pressed play. It either worked, timed out, or showed the
|
||||
// "Play in VLC" codec error
|
||||
// 'Play in VLC' codec error
|
||||
function logPlayAttempt (result) {
|
||||
if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) {
|
||||
return console.error('Unknown play attempt result', result)
|
||||
}
|
||||
|
||||
var attempts = telemetry.playAttempts
|
||||
const attempts = telemetry.playAttempts
|
||||
attempts.total = (attempts.total || 0) + 1
|
||||
attempts[result] = (attempts[result] || 0) + 1
|
||||
}
|
||||
61
src/renderer/lib/torrent-player.js
Normal file
61
src/renderer/lib/torrent-player.js
Normal file
@@ -0,0 +1,61 @@
|
||||
module.exports = {
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio,
|
||||
isTorrent,
|
||||
isPlayableTorrentSummary
|
||||
}
|
||||
|
||||
const path = require('path')
|
||||
|
||||
// Checks whether a fileSummary or file path is audio/video that we can play,
|
||||
// based on the file extension
|
||||
function isPlayable (file) {
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
// Checks whether a fileSummary or file path is playable video
|
||||
function isVideo (file) {
|
||||
return [
|
||||
'.avi',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpg',
|
||||
'.ogv',
|
||||
'.webm',
|
||||
'.wmv'
|
||||
].includes(getFileExtension(file))
|
||||
}
|
||||
|
||||
// Checks whether a fileSummary or file path is playable audio
|
||||
function isAudio (file) {
|
||||
return [
|
||||
'.aac',
|
||||
'.ac3',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.m4a'
|
||||
].includes(getFileExtension(file))
|
||||
}
|
||||
|
||||
// Checks if the argument is either:
|
||||
// - a string that's a valid filename ending in .torrent
|
||||
// - a file object where obj.name is ends in .torrent
|
||||
// - a string that's a magnet link (magnet://...)
|
||||
function isTorrent (file) {
|
||||
const isTorrentFile = getFileExtension(file) === '.torrent'
|
||||
const isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file)
|
||||
return isTorrentFile || isMagnet
|
||||
}
|
||||
|
||||
function getFileExtension (file) {
|
||||
const name = typeof file === 'string' ? file : file.name
|
||||
return path.extname(name).toLowerCase()
|
||||
}
|
||||
|
||||
function isPlayableTorrentSummary (torrentSummary) {
|
||||
return torrentSummary.files && torrentSummary.files.some(isPlayable)
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
module.exports = torrentPoster
|
||||
|
||||
var captureVideoFrame = require('./capture-video-frame')
|
||||
var path = require('path')
|
||||
const captureVideoFrame = require('./capture-video-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 = captureVideoFrame(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))
|
||||
}
|
||||
57
src/renderer/lib/torrent-summary.js
Normal file
57
src/renderer/lib/torrent-summary.js
Normal file
@@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
getPosterPath,
|
||||
getTorrentPath,
|
||||
getByKey,
|
||||
getTorrentID,
|
||||
getFileOrFolder
|
||||
}
|
||||
|
||||
const path = require('path')
|
||||
const config = require('../../config')
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the torrent file, or null if unavailable
|
||||
function getTorrentPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.torrentFileName) return null
|
||||
return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName)
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the poster image, or null if unavailable
|
||||
function getPosterPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.posterFileName) return null
|
||||
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, '/')
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns a torrentID: filename, magnet URI, or infohash
|
||||
function getTorrentID (torrentSummary) {
|
||||
const s = torrentSummary
|
||||
if (s.torrentFileName) { // Load torrent file from disk
|
||||
return getTorrentPath(s)
|
||||
} else { // Load torrent from DHT
|
||||
return s.magnetURI || s.infoHash
|
||||
}
|
||||
}
|
||||
|
||||
// Expects a torrentKey or infoHash
|
||||
// Returns the corresponding torrentSummary, or undefined
|
||||
function getByKey (state, torrentKey) {
|
||||
if (!torrentKey) return undefined
|
||||
return state.saved.torrents.find((x) =>
|
||||
x.torrentKey === torrentKey || x.infoHash === torrentKey)
|
||||
}
|
||||
|
||||
// Returns the path to either the file (in a single-file torrent) or the root
|
||||
// folder (in multi-file torrent)
|
||||
// WARNING: assumes that multi-file torrents consist of a SINGLE folder.
|
||||
// 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) {
|
||||
const ts = torrentSummary
|
||||
if (!ts.path || !ts.files || ts.files.length === 0) return null
|
||||
return path.join(ts.path, ts.files[0].path.split('/')[0])
|
||||
}
|
||||
469
src/renderer/main.js
Normal file
469
src/renderer/main.js
Normal file
@@ -0,0 +1,469 @@
|
||||
console.time('init')
|
||||
|
||||
const crashReporter = require('../crash-reporter')
|
||||
crashReporter.init()
|
||||
|
||||
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 State = require('./lib/state')
|
||||
const TorrentPlayer = require('./lib/torrent-player')
|
||||
|
||||
// Required by Material UI -- adds `onTouchTap` event
|
||||
require('react-tap-event-plugin')()
|
||||
|
||||
const App = require('./pages/app')
|
||||
|
||||
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')
|
||||
|
||||
// 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
|
||||
|
||||
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 = window.state = _state // Make available for easier debugging
|
||||
|
||||
telemetry.init(state)
|
||||
|
||||
// Log uncaught JS errors
|
||||
window.addEventListener('error',
|
||||
(e) => telemetry.logUncaughtError('window', e),
|
||||
true /* capture */)
|
||||
|
||||
// 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',
|
||||
setup: (cb) => {
|
||||
state.window.title = config.APP_WINDOW_TITLE
|
||||
cb(null)
|
||||
}
|
||||
})
|
||||
|
||||
// Restart everything we were torrenting last time the app ran
|
||||
resumeTorrents()
|
||||
|
||||
// Calling update() updates the UI given the current state
|
||||
// Do this at least once a second to give every file in every torrentSummary
|
||||
// a progress bar and to keep the cursor in sync when playing a video
|
||||
setInterval(update, 1000)
|
||||
app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
|
||||
|
||||
// Lazy-load other stuff, like the AppleTV module, later to keep startup fast
|
||||
window.setTimeout(delayedInit, config.DELAYED_INIT)
|
||||
|
||||
// Listen for messages from the main process
|
||||
setupIpc()
|
||||
|
||||
// Warn if the download dir is gone, eg b/c an external drive is unplugged
|
||||
checkDownloadPath()
|
||||
|
||||
// OS integrations:
|
||||
// ...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)
|
||||
|
||||
// ...window visibility state.
|
||||
document.addEventListener('webkitvisibilitychange', onVisibilityChange)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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),
|
||||
|
||||
// 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(),
|
||||
'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),
|
||||
'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, 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)
|
||||
|
||||
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('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
|
||||
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
|
||||
|
||||
const 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 (Mac)
|
||||
ipcRenderer.send('setAspectRatio', state.playing.aspectRatio)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
116
src/renderer/pages/app.js
Normal file
116
src/renderer/pages/app.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const React = require('react')
|
||||
|
||||
const darkBaseTheme = require('material-ui/styles/baseThemes/darkBaseTheme').default
|
||||
const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default
|
||||
const getMuiTheme = require('material-ui/styles/getMuiTheme').default
|
||||
const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default
|
||||
|
||||
const Header = require('../components/header')
|
||||
|
||||
const Views = {
|
||||
'home': require('./torrent-list-page'),
|
||||
'player': require('./player-page'),
|
||||
'create-torrent': require('./create-torrent-page'),
|
||||
'preferences': require('./preferences-page')
|
||||
}
|
||||
|
||||
const Modals = {
|
||||
'open-torrent-address-modal': require('../components/open-torrent-address-modal'),
|
||||
'remove-torrent-modal': require('../components/remove-torrent-modal'),
|
||||
'update-available-modal': require('../components/update-available-modal'),
|
||||
'unsupported-media-modal': require('../components/unsupported-media-modal')
|
||||
}
|
||||
|
||||
const fontFamily = process.platform === 'win32'
|
||||
? '"Segoe UI", sans-serif'
|
||||
: 'BlinkMacSystemFont, "Helvetica Neue", Helvetica, sans-serif'
|
||||
lightBaseTheme.fontFamily = fontFamily
|
||||
darkBaseTheme.fontFamily = fontFamily
|
||||
darkBaseTheme.palette.primary1Color = colors.cyan500
|
||||
darkBaseTheme.palette.primary2Color = colors.cyan500
|
||||
darkBaseTheme.palette.primary3Color = colors.grey600
|
||||
darkBaseTheme.palette.accent1Color = colors.redA200
|
||||
darkBaseTheme.palette.accent2Color = colors.redA400
|
||||
darkBaseTheme.palette.accent3Color = colors.redA100
|
||||
|
||||
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.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
|
||||
|
||||
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')
|
||||
|
||||
const vdom = (
|
||||
<MuiThemeProvider muiTheme={getMuiTheme(darkBaseTheme)}>
|
||||
<div className={'app ' + cls.join(' ')}>
|
||||
<Header state={state} />
|
||||
{this.getErrorPopover()}
|
||||
<div key='content' className='content'>{this.getView()}</div>
|
||||
{this.getModal()}
|
||||
</div>
|
||||
</MuiThemeProvider>
|
||||
)
|
||||
|
||||
return vdom
|
||||
}
|
||||
|
||||
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
|
||||
const ModalContents = Modals[state.modal.id]
|
||||
return (
|
||||
<MuiThemeProvider muiTheme={getMuiTheme(lightBaseTheme)}>
|
||||
<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
|
||||
216
src/renderer/pages/create-torrent-page.js
Normal file
216
src/renderer/pages/create-torrent-page.js
Normal 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'
|
||||
label='Cancel'
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
onClick={dispatcher('cancel')} />
|
||||
<RaisedButton
|
||||
className='control'
|
||||
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
|
||||
650
src/renderer/pages/player-page.js
Normal file
650
src/renderer/pages/player-page.js
Normal file
@@ -0,0 +1,650 @@
|
||||
const React = require('react')
|
||||
const Bitfield = require('bitfield')
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
const zeroFill = require('zero-fill')
|
||||
const path = require('path')
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const Playlist = require('../lib/playlist')
|
||||
const {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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'> </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') {
|
||||
// TODO: get the player name in a more reliable way
|
||||
const playerPath = state.saved.prefs.externalPlayerPath
|
||||
const playerName = playerPath ? path.basename(playerPath).split('.')[0] : 'VLC'
|
||||
castIcon = 'tv'
|
||||
castType = playerName
|
||||
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))'
|
||||
}
|
||||
|
||||
// TODO: dcposch change the range input to use value / onChanged instead of
|
||||
// "readonly" / onMouse[Down,Move,Up]
|
||||
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'>
|
||||
{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) {
|
||||
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
|
||||
}
|
||||
170
src/renderer/pages/preferences-page.js
Normal file
170
src/renderer/pages/preferences-page.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const path = require('path')
|
||||
const React = require('react')
|
||||
|
||||
const Checkbox = require('material-ui/Checkbox').default
|
||||
const Heading = require('../components/heading')
|
||||
const PathSelector = require('../components/path-selector')
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
|
||||
const {dispatch} = require('../lib/dispatcher')
|
||||
|
||||
class PreferencesPage extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.handleDownloadPathChange =
|
||||
this.handleDownloadPathChange.bind(this)
|
||||
|
||||
this.handleOpenExternalPlayerChange =
|
||||
this.handleOpenExternalPlayerChange.bind(this)
|
||||
|
||||
this.handleExternalPlayerPathChange =
|
||||
this.handleExternalPlayerPathChange.bind(this)
|
||||
}
|
||||
|
||||
downloadPathSelector () {
|
||||
return (
|
||||
<Preference>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select download directory',
|
||||
properties: [ 'openDirectory' ]
|
||||
}}
|
||||
onChange={this.handleDownloadPathChange}
|
||||
title='Download location'
|
||||
value={this.props.state.unsaved.prefs.downloadPath} />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleDownloadPathChange (filePath) {
|
||||
dispatch('updatePreferences', 'downloadPath', filePath)
|
||||
}
|
||||
|
||||
openExternalPlayerCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={!this.props.state.unsaved.prefs.openExternalPlayer}
|
||||
label={'Play torrent media files using WebTorrent'}
|
||||
onCheck={this.handleOpenExternalPlayerChange} />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleOpenExternalPlayerChange (e, isChecked) {
|
||||
dispatch('updatePreferences', 'openExternalPlayer', !isChecked)
|
||||
}
|
||||
|
||||
externalPlayerPathSelector () {
|
||||
const playerName = path.basename(
|
||||
this.props.state.unsaved.prefs.externalPlayerPath || 'VLC'
|
||||
)
|
||||
|
||||
const description = this.props.state.unsaved.prefs.openExternalPlayer
|
||||
? `Torrent media files will always play in ${playerName}.`
|
||||
: `Torrent media files will play in ${playerName} if WebTorrent cannot ` +
|
||||
'play them.'
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<p>{description}</p>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select media player app',
|
||||
properties: [ 'openFile' ]
|
||||
}}
|
||||
displayValue={playerName}
|
||||
onChange={this.handleExternalPlayerPathChange}
|
||||
title='External player'
|
||||
value={this.props.state.unsaved.prefs.externalPlayerPath} />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleExternalPlayerPathChange (filePath) {
|
||||
if (path.extname(filePath) === '.app') {
|
||||
// Mac: Use executable in packaged .app bundle
|
||||
filePath += '/Contents/MacOS/' + path.basename(filePath, '.app')
|
||||
}
|
||||
dispatch('updatePreferences', 'externalPlayerPath', filePath)
|
||||
}
|
||||
|
||||
setDefaultAppButton () {
|
||||
const isFileHandler = this.props.state.unsaved.prefs.isFileHandler
|
||||
if (isFileHandler) {
|
||||
return (
|
||||
<Preference>
|
||||
<p>WebTorrent is your default torrent app. Hooray!</p>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Preference>
|
||||
<p>WebTorrent is not currently the default torrent app.</p>
|
||||
<RaisedButton
|
||||
className='control'
|
||||
onClick={this.handleSetDefaultApp}
|
||||
label='Make WebTorrent the default' />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleSetDefaultApp () {
|
||||
dispatch('updatePreferences', 'isFileHandler', true)
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
color: colors.grey400,
|
||||
marginLeft: 25,
|
||||
marginRight: 25
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
<PreferencesSection title='Downloads'>
|
||||
{this.downloadPathSelector()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Playback'>
|
||||
{this.openExternalPlayerCheckbox()}
|
||||
{this.externalPlayerPathSelector()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Default torrent app'>
|
||||
{this.setDefaultAppButton()}
|
||||
</PreferencesSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PreferencesSection extends React.Component {
|
||||
static get propTypes () {
|
||||
return {
|
||||
title: React.PropTypes.string
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
marginBottom: 25,
|
||||
marginTop: 25
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
<Heading level={2}>{this.props.title}</Heading>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Preference extends React.Component {
|
||||
render () {
|
||||
const style = { marginBottom: 10 }
|
||||
return (<div style={style}>{this.props.children}</div>)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PreferencesPage
|
||||
387
src/renderer/pages/torrent-list-page.js
Normal file
387
src/renderer/pages/torrent-list-page.js
Normal file
@@ -0,0 +1,387 @@
|
||||
const React = require('react')
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const TorrentPlayer = require('../lib/torrent-player')
|
||||
const {dispatcher} = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class TorrentList extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
|
||||
const contents = []
|
||||
if (state.downloadPathStatus === 'missing') {
|
||||
contents.push(
|
||||
<div key='torrent-missing-path'>
|
||||
<p>Download path missing: {state.saved.prefs.downloadPath}</p>
|
||||
<p>Check that all drives are connected?</p>
|
||||
<p>Alternatively, choose a new download path
|
||||
in <a href='#' onClick={dispatcher('preferences')}>Preferences</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const torrentElems = state.saved.torrents.map(
|
||||
(torrentSummary) => this.renderTorrent(torrentSummary)
|
||||
)
|
||||
contents.push(...torrentElems)
|
||||
contents.push(
|
||||
<div key='torrent-placeholder' className='torrent-placeholder'>
|
||||
<span className='ellipsis'>Drop a torrent file here or paste a magnet link</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div key='torrent-list' className='torrent-list'>
|
||||
{contents}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderTorrent (torrentSummary) {
|
||||
const state = this.props.state
|
||||
const infoHash = torrentSummary.infoHash
|
||||
const isSelected = infoHash && state.selectedInfoHash === infoHash
|
||||
|
||||
// Background image: show some nice visuals, like a frame from the movie, if possible
|
||||
const style = {}
|
||||
if (torrentSummary.posterFileName) {
|
||||
const gradient = 'linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%)'
|
||||
const 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
|
||||
const classes = ['torrent']
|
||||
// playStatus turns the play button into a loading spinner or error icon
|
||||
if (torrentSummary.playStatus) classes.push(torrentSummary.playStatus)
|
||||
if (isSelected) classes.push('selected')
|
||||
if (!infoHash) classes.push('disabled')
|
||||
if (!torrentSummary.torrentKey) throw new Error('Missing torrentKey')
|
||||
return (
|
||||
<div
|
||||
key={torrentSummary.torrentKey}
|
||||
style={style}
|
||||
className={classes.join(' ')}
|
||||
onContextMenu={infoHash && dispatcher('openTorrentContextMenu', infoHash)}
|
||||
onClick={infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
|
||||
{this.renderTorrentMetadata(torrentSummary)}
|
||||
{infoHash ? this.renderTorrentButtons(torrentSummary) : null}
|
||||
{isSelected ? this.renderTorrentDetails(torrentSummary) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show name, download status, % complete
|
||||
renderTorrentMetadata (torrentSummary) {
|
||||
const name = torrentSummary.name || 'Loading torrent...'
|
||||
const elements = [(
|
||||
<div key='name' className='name ellipsis'>{name}</div>
|
||||
)]
|
||||
|
||||
// If it's downloading/seeding then show progress info
|
||||
const prog = torrentSummary.progress
|
||||
if (torrentSummary.error) {
|
||||
elements.push(
|
||||
<div key='progress-info' className='ellipsis'>
|
||||
{getErrorMessage(torrentSummary)}
|
||||
</div>
|
||||
)
|
||||
} else if (torrentSummary.status !== 'paused' && prog) {
|
||||
elements.push(
|
||||
<div key='progress-info' className='ellipsis'>
|
||||
{renderProgressBar()}
|
||||
{renderPercentProgress()}
|
||||
{renderTotalProgress()}
|
||||
{renderPeers()}
|
||||
{renderDownloadSpeed()}
|
||||
{renderUploadSpeed()}
|
||||
{renderEta()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (<div key='metadata' className='metadata'>{elements}</div>)
|
||||
|
||||
function renderProgressBar () {
|
||||
const progress = Math.floor(100 * prog.progress)
|
||||
return (<progress value={progress} max='100'>{progress}%</progress>)
|
||||
}
|
||||
|
||||
function renderPercentProgress () {
|
||||
const progress = Math.floor(100 * prog.progress)
|
||||
return (<span key='percent-progress'>{progress}%</span>)
|
||||
}
|
||||
|
||||
function renderTotalProgress () {
|
||||
const downloaded = prettyBytes(prog.downloaded)
|
||||
const total = prettyBytes(prog.length || 0)
|
||||
if (downloaded === total) {
|
||||
return (<span key='total-progress'>{downloaded}</span>)
|
||||
} else {
|
||||
return (<span key='total-progress'>{downloaded} / {total}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeers () {
|
||||
if (prog.numPeers === 0) return
|
||||
const count = prog.numPeers === 1 ? 'peer' : 'peers'
|
||||
return (<span key='peers'>{prog.numPeers} {count}</span>)
|
||||
}
|
||||
|
||||
function renderDownloadSpeed () {
|
||||
if (prog.downloadSpeed === 0) return
|
||||
return (<span key='download'>↓ {prettyBytes(prog.downloadSpeed)}/s</span>)
|
||||
}
|
||||
|
||||
function renderUploadSpeed () {
|
||||
if (prog.uploadSpeed === 0) return
|
||||
return (<span key='upload'>↑ {prettyBytes(prog.uploadSpeed)}/s</span>)
|
||||
}
|
||||
|
||||
function renderEta () {
|
||||
const downloaded = prog.downloaded
|
||||
const total = prog.length || 0
|
||||
const missing = total - downloaded
|
||||
const downloadSpeed = prog.downloadSpeed
|
||||
if (downloadSpeed === 0 || missing === 0) return
|
||||
|
||||
const rawEta = missing / downloadSpeed
|
||||
const hours = Math.floor(rawEta / 3600) % 24
|
||||
const minutes = Math.floor(rawEta / 60) % 60
|
||||
const seconds = Math.floor(rawEta % 60)
|
||||
|
||||
// Only display hours and minutes if they are greater than 0 but always
|
||||
// display minutes if hours is being displayed
|
||||
const hoursStr = hours ? hours + 'h' : ''
|
||||
const minutesStr = (hours || minutes) ? minutes + 'm' : ''
|
||||
const secondsStr = seconds + 's'
|
||||
|
||||
return (<span>ETA: {hoursStr} {minutesStr} {secondsStr}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
renderTorrentButtons (torrentSummary) {
|
||||
const infoHash = torrentSummary.infoHash
|
||||
|
||||
let 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'
|
||||
}
|
||||
|
||||
let 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.'
|
||||
}
|
||||
|
||||
// Only show the play/dowload buttons for torrents that contain playable media
|
||||
let playButton, downloadButton, positionElem
|
||||
if (!torrentSummary.error) {
|
||||
downloadButton = (
|
||||
<i
|
||||
key='download-button'
|
||||
className={'button-round icon download ' + torrentSummary.status}
|
||||
title={downloadTooltip}
|
||||
onClick={dispatcher('toggleTorrent', infoHash)}>
|
||||
{downloadIcon}
|
||||
</i>
|
||||
)
|
||||
|
||||
// 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:
|
||||
const willShowSpinner = torrentSummary.playStatus === 'requested'
|
||||
const mostRecentFile = torrentSummary.files &&
|
||||
torrentSummary.files[torrentSummary.mostRecentFileIndex]
|
||||
if (mostRecentFile && mostRecentFile.currentTime && !willShowSpinner) {
|
||||
const fraction = mostRecentFile.currentTime / mostRecentFile.duration
|
||||
positionElem = this.renderRadialProgressBar(fraction, 'radial-progress-large')
|
||||
playClass = 'resume-position'
|
||||
}
|
||||
|
||||
if (TorrentPlayer.isPlayableTorrentSummary(torrentSummary)) {
|
||||
playButton = (
|
||||
<i
|
||||
key='play-button'
|
||||
title={playTooltip}
|
||||
className={'button-round icon play ' + playClass}
|
||||
onClick={dispatcher('playFile', infoHash)}>
|
||||
{playIcon}
|
||||
</i>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='buttons' className='buttons'>
|
||||
{positionElem}
|
||||
{playButton}
|
||||
{downloadButton}
|
||||
<i
|
||||
key='delete-button'
|
||||
className='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
|
||||
renderTorrentDetails (torrentSummary) {
|
||||
let filesElement
|
||||
if (torrentSummary.error || !torrentSummary.files) {
|
||||
let message = ''
|
||||
if (torrentSummary.error === 'path-missing') {
|
||||
// Special case error: this torrent's download dir or file is missing
|
||||
message = 'Missing path: ' + TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
} else if (torrentSummary.error) {
|
||||
// General error for this torrent: just show the message
|
||||
message = torrentSummary.error.message || torrentSummary.error
|
||||
} else if (torrentSummary.status === 'paused') {
|
||||
// No file info, no infohash, and we're not trying to download from the DHT
|
||||
message = 'Failed to load torrent info. Click the download button to try again...'
|
||||
} else {
|
||||
// No file info, no infohash, trying to load from the DHT
|
||||
message = 'Downloading torrent info...'
|
||||
}
|
||||
filesElement = (
|
||||
<div key='files' className='files warning'>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// We do know the files. List them and show download stats for each one
|
||||
const fileRows = torrentSummary.files
|
||||
.filter((file) => !file.path.includes('/.____padding_file/'))
|
||||
.map((file, index) => ({ file, index }))
|
||||
.map((object) => this.renderFileRow(torrentSummary, object.file, object.index))
|
||||
|
||||
filesElement = (
|
||||
<div key='files' className='files'>
|
||||
<table>
|
||||
<tbody>
|
||||
{fileRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='details' className='torrent-details'>
|
||||
{filesElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show a single torrentSummary file in the details view for a single torrent
|
||||
renderFileRow (torrentSummary, file, index) {
|
||||
// First, find out how much of the file we've downloaded
|
||||
// Are we even torrenting it?
|
||||
const isSelected = torrentSummary.selections && torrentSummary.selections[index]
|
||||
let isDone = false // Are we finished torrenting it?
|
||||
let progress = ''
|
||||
if (torrentSummary.progress && torrentSummary.progress.files &&
|
||||
torrentSummary.progress.files[index]) {
|
||||
const 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
|
||||
let positionElem
|
||||
if (file.currentTime) {
|
||||
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
|
||||
positionElem = this.renderRadialProgressBar(file.currentTime / file.duration)
|
||||
}
|
||||
|
||||
// Finally, render the file as a table row
|
||||
const isPlayable = TorrentPlayer.isPlayable(file)
|
||||
const infoHash = torrentSummary.infoHash
|
||||
let icon
|
||||
let 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 = isDone
|
||||
? dispatcher('openItem', infoHash, index)
|
||||
: (e) => e.stopPropagation() // noop if file is not ready
|
||||
}
|
||||
// TODO: add a css 'disabled' class to indicate that a file cannot be opened/streamed
|
||||
let rowClass = ''
|
||||
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
|
||||
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
|
||||
return (
|
||||
<tr key={index} onClick={handleClick}>
|
||||
<td className={'col-icon ' + rowClass}>
|
||||
{positionElem}
|
||||
<i className='icon'>{icon}</i>
|
||||
</td>
|
||||
<td className={'col-name ' + rowClass}>
|
||||
{file.name}
|
||||
</td>
|
||||
<td className={'col-progress ' + rowClass}>
|
||||
{isSelected ? progress : ''}
|
||||
</td>
|
||||
<td className={'col-size ' + rowClass}>
|
||||
{prettyBytes(file.length)}
|
||||
</td>
|
||||
<td className='col-select'
|
||||
onClick={dispatcher('toggleTorrentFile', infoHash, index)}>
|
||||
<i className='icon'>{isSelected ? 'close' : 'add'}</i>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
renderRadialProgressBar (fraction, cssClass) {
|
||||
const rotation = 360 * fraction
|
||||
const transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
|
||||
const transformFix = {transform: 'rotate(' + rotation + 'deg)'}
|
||||
|
||||
return (
|
||||
<div key='radial-progress' className={'radial-progress ' + cssClass}>
|
||||
<div key='circle' className='circle'>
|
||||
<div key='mask-full' className='mask full' style={transformFill}>
|
||||
<div key='fill' className='fill' style={transformFill} />
|
||||
</div>
|
||||
<div key='mask-half' className='mask half'>
|
||||
<div key='fill' className='fill' style={transformFill} />
|
||||
<div key='fill-fix' className='fill fix' style={transformFix} />
|
||||
</div>
|
||||
</div>
|
||||
<div key='inset' className='inset' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage (torrentSummary) {
|
||||
const err = torrentSummary.error
|
||||
if (err === 'path-missing') {
|
||||
return (
|
||||
<span>
|
||||
Path missing.<br />
|
||||
Fix and restart the app, or delete the torrent.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return 'Error'
|
||||
}
|
||||
@@ -2,39 +2,68 @@
|
||||
// process from the main window.
|
||||
console.time('init')
|
||||
|
||||
var WebTorrent = require('webtorrent')
|
||||
var defaultAnnounceList = require('create-torrent').announceList
|
||||
var deepEqual = require('deep-equal')
|
||||
var electron = require('electron')
|
||||
var fs = require('fs-extra')
|
||||
var musicmetadata = require('musicmetadata')
|
||||
var networkAddress = require('network-address')
|
||||
var path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const deepEqual = require('deep-equal')
|
||||
const defaultAnnounceList = require('create-torrent').announceList
|
||||
const electron = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const musicmetadata = require('musicmetadata')
|
||||
const networkAddress = require('network-address')
|
||||
const path = require('path')
|
||||
const WebTorrent = require('webtorrent')
|
||||
const zeroFill = require('zero-fill')
|
||||
|
||||
var crashReporter = require('../crash-reporter')
|
||||
var config = require('../config')
|
||||
var torrentPoster = require('./lib/torrent-poster')
|
||||
const crashReporter = require('../crash-reporter')
|
||||
const config = require('../config')
|
||||
const torrentPoster = require('./lib/torrent-poster')
|
||||
|
||||
// Report when the process crashes
|
||||
crashReporter.init()
|
||||
|
||||
// Send & receive messages from the main window
|
||||
var ipc = electron.ipcRenderer
|
||||
const ipc = electron.ipcRenderer
|
||||
|
||||
// Force use of webtorrent trackers on all torrents
|
||||
global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
|
||||
.map((arr) => arr[0])
|
||||
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
|
||||
|
||||
/**
|
||||
* WebTorrent version.
|
||||
*/
|
||||
const VERSION = require('../../package.json').version
|
||||
|
||||
/**
|
||||
* Version number in Azureus-style. Generated from major and minor semver version.
|
||||
* For example:
|
||||
* '0.16.1' -> '0016'
|
||||
* '1.2.5' -> '0102'
|
||||
*/
|
||||
const VERSION_STR = VERSION.match(/([0-9]+)/g)
|
||||
.slice(0, 2)
|
||||
.map((v) => zeroFill(2, v))
|
||||
.join('')
|
||||
|
||||
/**
|
||||
* Version prefix string (used in peer ID). WebTorrent uses the Azureus-style
|
||||
* encoding: '-', two characters for client id ('WW'), four ascii digits for version
|
||||
* number, '-', followed by random numbers.
|
||||
* For example:
|
||||
* '-WW0102-'...
|
||||
*/
|
||||
const VERSION_PREFIX = '-WD' + VERSION_STR + '-'
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
|
||||
// client, as explained here: https://webtorrent.io/faq
|
||||
var client = new WebTorrent()
|
||||
const client = window.client = new WebTorrent({
|
||||
peerId: Buffer.from(VERSION_PREFIX + crypto.randomBytes(6).toString('hex'))
|
||||
})
|
||||
|
||||
// WebTorrent-to-HTTP streaming sever
|
||||
var server = null
|
||||
let server = null
|
||||
|
||||
// Used for diffing, so we only send progress updates when necessary
|
||||
var prevProgress = null
|
||||
let prevProgress = null
|
||||
|
||||
init()
|
||||
|
||||
@@ -54,8 +83,8 @@ function init () {
|
||||
generateTorrentPoster(torrentKey))
|
||||
ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
|
||||
getAudioMetadata(infoHash, index))
|
||||
ipc.on('wt-start-server', (e, infoHash, index) =>
|
||||
startServer(infoHash, index))
|
||||
ipc.on('wt-start-server', (e, infoHash) =>
|
||||
startServer(infoHash))
|
||||
ipc.on('wt-stop-server', (e) =>
|
||||
stopServer())
|
||||
ipc.on('wt-select-files', (e, infoHash, selections) =>
|
||||
@@ -68,14 +97,15 @@ function init () {
|
||||
true)
|
||||
|
||||
setInterval(updateTorrentProgress, 1000)
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object
|
||||
// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc.
|
||||
// Returns a WebTorrent object. See https://git.io/vik9M
|
||||
function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections) {
|
||||
console.log('starting torrent %s: %s', torrentKey, torrentID)
|
||||
|
||||
var torrent = client.add(torrentID, {
|
||||
const torrent = client.add(torrentID, {
|
||||
path: path,
|
||||
fileModtimes: fileModtimes
|
||||
})
|
||||
@@ -91,15 +121,15 @@ function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections)
|
||||
}
|
||||
|
||||
function stopTorrenting (infoHash) {
|
||||
var torrent = client.get(infoHash)
|
||||
const torrent = client.get(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
}
|
||||
|
||||
// Create a new torrent, start seeding
|
||||
function createTorrent (torrentKey, options) {
|
||||
console.log('creating torrent', torrentKey, options)
|
||||
var paths = options.files.map((f) => f.path)
|
||||
var torrent = client.seed(paths, options)
|
||||
const paths = options.files.map((f) => f.path)
|
||||
const torrent = client.seed(paths, options)
|
||||
torrent.key = torrentKey
|
||||
addTorrentEvents(torrent)
|
||||
ipc.send('wt-new-torrent')
|
||||
@@ -117,14 +147,14 @@ function addTorrentEvents (torrent) {
|
||||
torrent.on('done', torrentDone)
|
||||
|
||||
function torrentMetadata () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-metadata', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentReady () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-ready', torrent.key, info)
|
||||
ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info) // TODO: hack
|
||||
|
||||
@@ -132,7 +162,7 @@ function addTorrentEvents (torrent) {
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
var info = getTorrentInfo(torrent)
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipc.send('wt-done', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
@@ -168,9 +198,9 @@ function getTorrentFileInfo (file) {
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we never
|
||||
// have to download it again. Never ask the DHT the same question twice.
|
||||
function saveTorrentFile (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
const torrent = getTorrent(torrentKey)
|
||||
checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) {
|
||||
var fileName = torrent.infoHash + '.torrent'
|
||||
const fileName = torrent.infoHash + '.torrent'
|
||||
if (exists) {
|
||||
// We've already saved the file
|
||||
return ipc.send('wt-file-saved', torrentKey, fileName)
|
||||
@@ -190,7 +220,7 @@ function saveTorrentFile (torrentKey) {
|
||||
// Checks whether we've already resolved a given infohash to a torrent file
|
||||
// Calls back with (torrentPath, exists). Logs, does not call back on error
|
||||
function checkIfTorrentFileExists (infoHash, cb) {
|
||||
var torrentPath = path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
const torrentPath = path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
fs.exists(torrentPath, function (exists) {
|
||||
cb(torrentPath, exists)
|
||||
})
|
||||
@@ -199,14 +229,14 @@ function checkIfTorrentFileExists (infoHash, cb) {
|
||||
// Save a JPG that represents a torrent.
|
||||
// Auto chooses either a frame from a video file, an image, etc
|
||||
function generateTorrentPoster (torrentKey) {
|
||||
var torrent = getTorrent(torrentKey)
|
||||
const torrent = getTorrent(torrentKey)
|
||||
torrentPoster(torrent, function (err, buf, extension) {
|
||||
if (err) return console.log('error generating poster: %o', err)
|
||||
// save it for next time
|
||||
fs.mkdirp(config.POSTER_PATH, function (err) {
|
||||
if (err) return console.log('error creating poster dir: %o', err)
|
||||
var posterFileName = torrent.infoHash + extension
|
||||
var posterFilePath = path.join(config.POSTER_PATH, posterFileName)
|
||||
const posterFileName = torrent.infoHash + extension
|
||||
const posterFilePath = path.join(config.POSTER_PATH, posterFileName)
|
||||
fs.writeFile(posterFilePath, buf, function (err) {
|
||||
if (err) return console.log('error saving poster: %o', err)
|
||||
// show the poster
|
||||
@@ -217,7 +247,7 @@ function generateTorrentPoster (torrentKey) {
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
var progress = getTorrentProgress()
|
||||
const progress = getTorrentProgress()
|
||||
// TODO: diff torrent-by-torrent, not once for the whole update
|
||||
if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) {
|
||||
return /* don't send heavy object if it hasn't changed */
|
||||
@@ -228,19 +258,19 @@ function updateTorrentProgress () {
|
||||
|
||||
function getTorrentProgress () {
|
||||
// First, track overall progress
|
||||
var progress = client.progress
|
||||
var hasActiveTorrents = client.torrents.some(function (torrent) {
|
||||
const progress = client.progress
|
||||
const hasActiveTorrents = client.torrents.some(function (torrent) {
|
||||
return torrent.progress !== 1
|
||||
})
|
||||
|
||||
// Track progress for every file in each torrent
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
var torrentProg = client.torrents.map(function (torrent) {
|
||||
var fileProg = torrent.files && torrent.files.map(function (file, index) {
|
||||
var numPieces = file._endPiece - file._startPiece + 1
|
||||
var numPiecesPresent = 0
|
||||
for (var piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
const torrentProg = client.torrents.map(function (torrent) {
|
||||
const fileProg = torrent.files && torrent.files.map(function (file, index) {
|
||||
const numPieces = file._endPiece - file._startPiece + 1
|
||||
let numPiecesPresent = 0
|
||||
for (let piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
return {
|
||||
@@ -271,28 +301,28 @@ function getTorrentProgress () {
|
||||
}
|
||||
}
|
||||
|
||||
function startServer (infoHash, index) {
|
||||
var torrent = client.get(infoHash)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent, index)
|
||||
else torrent.once('ready', () => startServerFromReadyTorrent(torrent, index))
|
||||
function startServer (infoHash) {
|
||||
const torrent = client.get(infoHash)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent)
|
||||
else torrent.once('ready', () => startServerFromReadyTorrent(torrent))
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent, index, cb) {
|
||||
function startServerFromReadyTorrent (torrent, cb) {
|
||||
if (server) return
|
||||
|
||||
// start the streaming torrent-to-http server
|
||||
server = torrent.createServer()
|
||||
server.listen(0, function () {
|
||||
var port = server.address().port
|
||||
var urlSuffix = ':' + port + '/' + index
|
||||
var info = {
|
||||
const port = server.address().port
|
||||
const urlSuffix = ':' + port
|
||||
const info = {
|
||||
torrentKey: torrent.key,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix
|
||||
}
|
||||
|
||||
ipc.send('wt-server-running', info)
|
||||
ipc.send('wt-server-' + torrent.infoHash, info) // TODO: hack
|
||||
ipc.send('wt-server-' + torrent.infoHash, info)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -303,8 +333,8 @@ function stopServer () {
|
||||
}
|
||||
|
||||
function getAudioMetadata (infoHash, index) {
|
||||
var torrent = client.get(infoHash)
|
||||
var file = torrent.files[index]
|
||||
const torrent = client.get(infoHash)
|
||||
const file = torrent.files[index]
|
||||
musicmetadata(file.createReadStream(), function (err, info) {
|
||||
if (err) return
|
||||
console.log('got audio metadata for %s: %o', file.name, info)
|
||||
@@ -314,7 +344,7 @@ function getAudioMetadata (infoHash, index) {
|
||||
|
||||
function selectFiles (torrentOrInfoHash, selections) {
|
||||
// Get the torrent object
|
||||
var torrent
|
||||
let torrent
|
||||
if (typeof torrentOrInfoHash === 'string') {
|
||||
torrent = client.get(torrentOrInfoHash)
|
||||
} else {
|
||||
@@ -339,8 +369,8 @@ function selectFiles (torrentOrInfoHash, selections) {
|
||||
torrent.deselect(0, torrent.pieces.length - 1, false)
|
||||
|
||||
// Add selections (individual files)
|
||||
for (var i = 0; i < selections.length; i++) {
|
||||
var file = torrent.files[i]
|
||||
for (let i = 0; i < selections.length; i++) {
|
||||
const file = torrent.files[i]
|
||||
if (selections[i]) {
|
||||
file.select()
|
||||
} else {
|
||||
@@ -353,7 +383,7 @@ function selectFiles (torrentOrInfoHash, selections) {
|
||||
// Gets a WebTorrent handle by torrentKey
|
||||
// Throws an Error if we're not currently torrenting anything w/ that key
|
||||
function getTorrent (torrentKey) {
|
||||
var ret = client.torrents.find((x) => x.key === torrentKey)
|
||||
const ret = client.torrents.find((x) => x.key === torrentKey)
|
||||
if (!ret) throw new Error('missing torrent key ' + torrentKey)
|
||||
return ret
|
||||
}
|
||||
0
static/MaterialIcons-Regular.woff2
Normal file → Executable file
0
static/MaterialIcons-Regular.woff2
Normal file → Executable file
BIN
static/NextTrackThumbnailBarButton.png
Normal file
BIN
static/NextTrackThumbnailBarButton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 B |
BIN
static/PreviousTrackThumbnailBarButton.png
Normal file
BIN
static/PreviousTrackThumbnailBarButton.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user