Compare commits

..

13 Commits
esm ... v0.18.0

Author SHA1 Message Date
Feross Aboukhadijeh
f5d0692a56 0.18.0 2017-02-03 02:04:28 -08:00
Feross Aboukhadijeh
2f4fe3e521 disable background throttling in all windows 2017-02-03 02:04:17 -08:00
Feross Aboukhadijeh
4937f61199 move Transfers menu 2017-02-03 02:03:47 -08:00
Feross Aboukhadijeh
e74b993ecd updated copyright year 2017-02-03 02:03:12 -08:00
Feross Aboukhadijeh
36be5b2556 changelog for 0.18.0 2017-02-03 02:02:59 -08:00
Feross Aboukhadijeh
6a3aaba01f test: update test for new default trackers 2017-02-03 00:52:09 -08:00
Feross Aboukhadijeh
c50783d058 test: update screenshot for new default trackers 2017-02-03 00:51:58 -08:00
Feross Aboukhadijeh
32b251fa58 remove unneeded package from depcheck 2017-02-03 00:50:08 -08:00
Feross Aboukhadijeh
fcf087810e package: fix deprecation warning 2017-02-03 00:24:58 -08:00
Feross Aboukhadijeh
0124412d3d lock down dev dependencies
Let's be very explicit about updating these. They are very critical to
the build process, so we should always read the changelogs / commit log
before upgrading.
2017-02-03 00:24:41 -08:00
Feross Aboukhadijeh
dd570d9250 electron-osx-sign@0.4 2017-02-03 00:12:16 -08:00
Feross Aboukhadijeh
64add5d68b electron@1.4.15 2017-02-03 00:07:30 -08:00
Feross Aboukhadijeh
36a43b645e buble@0.15 2017-02-02 21:01:28 -08:00
153 changed files with 2668 additions and 29508 deletions

11
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,11 @@
<!-- DO NOT POST LINKS OR REFERENCES TO COPYRIGHTED CONTENT IN YOUR ISSUE. -->
**What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu)
**What operating system and version?**
**What did you do?**
**What did you expect to happen?**
**What actually happened?**

View File

@@ -1,20 +0,0 @@
---
name: "🐞 Bug report"
about: Report an issue with this software
title: ''
labels: ''
assignees: ''
---
<!-- DO NOT POST LINKS OR REFERENCES TO COPYRIGHTED CONTENT IN YOUR ISSUE. -->
**What version of WebTorrent Desktop are you using?**
**What operating system and version?**
**What happened?**
**What did you expect to happen?**
**Are you willing to submit a pull request to fix this bug?**

View File

@@ -1,20 +0,0 @@
---
name: "⭐️ Feature request"
about: Request a new feature to be added
title: ''
labels: ''
assignees: ''
---
<!-- DO NOT POST LINKS OR REFERENCES TO COPYRIGHTED CONTENT IN YOUR ISSUE. -->
**What version of WebTorrent Desktop are you using?**
**What operating system and version?**
**What problem do you want to solve?**
**What do you think is the correct solution to this problem?**
**Are you willing to submit a pull request to implement this change?**

View File

@@ -1,21 +0,0 @@
name: ci
on: [push,pull_request]
jobs:
test:
name: Node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
node:
- '16'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npm run build --if-present
- run: npm test

View File

@@ -1,69 +0,0 @@
name: package
on:
workflow_dispatch:
jobs:
package_linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm install
- run: npm run package -- linux
- uses: actions/upload-artifact@v2
with:
name: linux
path: |
dist/*.deb
dist/*.rpm
dist/*.zip
package_macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm install
- run: npm run package -- darwin
- uses: actions/upload-artifact@v2
with:
name: macos
path: |
dist/*.dmg
dist/*.zip
package_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm install
- run: npm run package -- win32
- uses: actions/upload-artifact@v2
with:
name: windows
path: |
dist/*.exe
dist/*.nupkg
dist/*.zip

View File

@@ -1,23 +0,0 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: '0 12 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward?'
stale-pr-message: 'Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward?'
exempt-issue-labels: accepted,blocked,bug,dependency,enhancement,'help wanted',question,security,meta
exempt-pr-labels: accepted,blocked,bug,dependency,enhancement,'help wanted',question,security,meta
stale-issue-label: 'stale'
stale-pr-label: 'stale'

5
.gitignore vendored
View File

@@ -2,8 +2,3 @@ node_modules/
build/ build/
dist/ dist/
npm-debug.log* npm-debug.log*
.vscode/
# JetBrains IntelliJ IDEA project files
.idea
*.iml

1
.npmrc
View File

@@ -1 +0,0 @@
package-lock=true

4
.travis.yml Normal file
View File

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

View File

@@ -34,50 +34,5 @@
- PurgingPanda (t3ch0wn3r@gmail.com) - PurgingPanda (t3ch0wn3r@gmail.com)
- Kai Curtis (morecode@kcurtis.com) - Kai Curtis (morecode@kcurtis.com)
- Omri Litov (omrilitov@gmail.com) - Omri Litov (omrilitov@gmail.com)
- Alexey Romanov (romanalexey@gmail.com)
- Karan Thakkar (karanjthakkar@gmail.com)
- Nuno Campos (nuno.campos@me.com)
- Ebrahim Byagowi (ebrahim@gnu.org)
- Josip Janzic (josip@jjanzic.com)
- Egor Yurtaev (yurtaev.egor@gmail.com)
- Emil Bay (github@tixz.dk)
- Borewit (borewit@users.noreply.github.com)
- greenkeeper[bot] (greenkeeper[bot]@users.noreply.github.com)
- Auyer (rafa_auyer@icloud.com)
- Jon Koops (jonkoops@gmail.com)
- Michael George Attard (michaelgeorgeattard@gmail.com)
- SimplyAhmazing (ahmad19526@gmail.com)
- Cezar Carneiro (cezargcarneiro@gmail.com)
- Bilal Elmoussaoui (bil.elmoussaoui@gmail.com)
- Terry Hau (terryhau@gmail.com)
- Vítor Galvão (info@vitorgalvao.com)
- Borewit (Borewit@users.noreply.github.com)
- Diego Rodríguez (diegorbaquero@gmail.com)
- Dan Flettre (flettre@gmail.com)
- Sibiraj (dev.sibiraj@outlook.com)
- clujin (clujin@gmail.com)
- Sharon Grossman (sharong1337@gmail.com)
- Linus Unnebäck (linus@folkdatorn.se)
- Adrian Tombu (adrian@otso.fr)
- Lucas (5874806+RecoX@users.noreply.github.com)
- David Ernst (dsernst@users.noreply.github.com)
- David Ernst (git@dsernst.com)
- Jimmy Wärting (jimmy@warting.se)
- Recox (5874806+RecoX@users.noreply.github.com)
- greenkeeper[bot] (23040076+greenkeeper[bot]@users.noreply.github.com)
- hicom150 (hicom150@gmail.com)
- Дамјан Георгиевски (gdamjan@gmail.com)
- Jimmy Wärting (jimmy@warting.se)
- Julen Garcia Leunda (hicom150@gmail.com)
- Feross (feross@feross.org)
- Daniele Debernardi (drebrez@gmail.com)
- Chandan Chowdary Bhagam (chandandharana@gmail.com)
- Pieter Goetschalckx (3.14.e.ter@gmail.com)
- Carey Metcalfe (carey@cmetcalfe.ca)
- Ameet Kaustav (akaustav@users.noreply.github.com)
- gpatarin (gael.patarin@outlook.com)
- Gael Patarin (gael.patarin@outlook.com)
- Subin Siby (mail@subinsb.com)
- Hinara (hinara.turevel@gmail.com)
#### Generated by bin/update-authors.sh. #### Generated by bin/update-authors.sh.

View File

@@ -1,128 +1,6 @@
# WebTorrent Desktop Version History # WebTorrent Desktop Version History
## v0.24.0 - 2020-08-28 ## v0.18.0
### Added
- Support the `.m2ts` video container format ([hicom150](https://github.com/hicom150))
### Changed
- Update to Electron 10.1.0 [\#1864](https://github.com/webtorrent/webtorrent-desktop/pull/1864) ([feross](https://github.com/feross))
- Update the Windows installer loading image [\#1841](https://github.com/webtorrent/webtorrent-desktop/pull/1841) ([alxhotel](https://github.com/alxhotel))
### Fixed
- Fix music metadata not showing up [\#1847](https://github.com/webtorrent/webtorrent-desktop/pull/1847) ([Borewit](https://github.com/Borewit))
- Fix the "Play in VLC" functionality [\#1850](https://github.com/webtorrent/webtorrent-desktop/pull/1850) ([Hinara](https://github.com/Hinara))
- Prevent shortcuts from activating when user input elements are focused [\#1840](https://github.com/webtorrent/webtorrent-desktop/pull/1840) ([subins2000](https://github.com/subins2000))
## v0.23.0 - 2020-07-15
🔒 This release contains a critical security fix. Please update as soon as possible. [\#1837](https://github.com/webtorrent/webtorrent-desktop/issues/1837#issuecomment-729320901)
### Added
- Add macOS Notarization [\#1834](https://github.com/webtorrent/webtorrent-desktop/pull/1834) ([feross](https://github.com/feross))
### Changed
- Update to Electron 10 beta [\#1834](https://github.com/webtorrent/webtorrent-desktop/pull/1834) ([feross](https://github.com/feross))
## v0.22.0 - 2020-07-15
❤️✨ A new version of WebTorrent Desktop is out! ❤️✨
### Added
- Linux `.rpm` packages and `arm64` builds are now available! [\#1694](https://github.com/webtorrent/webtorrent-desktop/pull/1694) ([hicom150](https://github.com/hicom150))
- Add support for multiple audio tracks [\#1712](https://github.com/webtorrent/webtorrent-desktop/pull/1712) ([hicom150](https://github.com/hicom150))
- Improve codec unsupported detection [\#1711](https://github.com/webtorrent/webtorrent-desktop/pull/1711) ([hicom150](https://github.com/hicom150))
- Report when files are being verified [\#1717](https://github.com/webtorrent/webtorrent-desktop/pull/1717) ([pR0Ps](https://github.com/pR0Ps))
- Support additional audio files: MPEG-Layer-2, Musepack, Matroska audio, WavePack [\#1772](https://github.com/webtorrent/webtorrent-desktop/pull/1772)
### Changed
- Update to Electron 9 [\#1729](https://github.com/webtorrent/webtorrent-desktop/pull/1729) [\#1832](https://github.com/webtorrent/webtorrent-desktop/issues/1832)
- Update to music-metadata 4.8.0 [\#1719](https://github.com/webtorrent/webtorrent-desktop/pull/1719) ([Borewit](https://github.com/Borewit))
- Update Windows build documentation [\#1715](https://github.com/webtorrent/webtorrent-desktop/pull/1715) ([RecoX](https://github.com/RecoX))
- Remove unneeded dependencies ([feross](https://github.com/feross))
### Fixed
- Fix a few type errors [\#1720](https://github.com/webtorrent/webtorrent-desktop/pull/1720) ([mathiasvr](https://github.com/mathiasvr))
- Fix electron SUID sandbox error [\#1707](https://github.com/webtorrent/webtorrent-desktop/pull/1707) ([hicom150](https://github.com/hicom150))
- Fix percentage rounding error [\#1716](https://github.com/webtorrent/webtorrent-desktop/pull/1716) ([pR0Ps](https://github.com/pR0Ps))
- Fix path-selector in preferences page [\#1702](https://github.com/webtorrent/webtorrent-desktop/pull/1702) ([314eter](https://github.com/314eter))
- Fix path-selector in preferences page [\#1704](https://github.com/webtorrent/webtorrent-desktop/pull/1702) ([mathiasvr](https://github.com/mathiasvr))
- Fix: Increase height of 'About' window [\#1737](https://github.com/webtorrent/webtorrent-desktop/pull/1737) ([akaustav](https://github.com/akaustav))
- Fix "Save Torrent File As..." [\#1743](https://github.com/webtorrent/webtorrent-desktop/pull/1743) ([gpatarin](https://github.com/gpatarin))
## v0.21.0 - 2019-09-14
### Added
- Add YouTube style hotkeys [\#1579](https://github.com/webtorrent/webtorrent-desktop/pull/1579) ([dsernst](https://github.com/dsernst))
- Toggle sound notifications on/off [\#1536](https://github.com/webtorrent/webtorrent-desktop/pull/1536) ([adriantombu](https://github.com/adriantombu))
- Ability to play MPEG-4 Audio Book \(.m4b\) [\#1450](https://github.com/webtorrent/webtorrent-desktop/pull/1450) ([Borewit](https://github.com/Borewit))
- Add support for subtitles on Chromecast [\#1165](https://github.com/webtorrent/webtorrent-desktop/pull/1165) ([janza](https://github.com/janza))
### Changed
- Update to Electron 4 [\#1590](https://github.com/webtorrent/webtorrent-desktop/pull/1590) ([Borewit](https://github.com/Borewit))
- Remove '\(BETA\)' from app window title [\#1562](https://github.com/webtorrent/webtorrent-desktop/pull/1562) ([dsernst](https://github.com/dsernst))
- Update React (v16) and Material-UI (v0.20) [\#1483](https://github.com/webtorrent/webtorrent-desktop/pull/1483) ([mathiasvr](https://github.com/mathiasvr))
- Show audio track and disk number [\#1454](https://github.com/webtorrent/webtorrent-desktop/pull/1454) ([Borewit](https://github.com/Borewit))
- Asynchronous music metadata updates while streaming [\#1449](https://github.com/webtorrent/webtorrent-desktop/pull/1449) ([Borewit](https://github.com/Borewit))
- If torrent is not private, leave private flag unset [\#1411](https://github.com/webtorrent/webtorrent-desktop/pull/1411) ([feross](https://github.com/feross))
- Improve audio poster selection: [\#1368](https://github.com/webtorrent/webtorrent-desktop/pull/1368) ([Borewit](https://github.com/Borewit))
- Save preferences immediately when changed [\#1042](https://github.com/webtorrent/webtorrent-desktop/pull/1042) ([Flet](https://github.com/Flet))
### Fixed
- Ensure that the minutes field in playback indicator is zero-padded. [\#1506](https://github.com/webtorrent/webtorrent-desktop/pull/1506) ([bnjmnt4n](https://github.com/bnjmnt4n))
- Bug Fix: Empty Array Reduce [\#1494](https://github.com/webtorrent/webtorrent-desktop/pull/1494) ([clujin](https://github.com/clujin))
- Fix startup problems [\#1419](https://github.com/webtorrent/webtorrent-desktop/pull/1419) ([Borewit](https://github.com/Borewit))
- Add back loading spinner for player page. [\#1311](https://github.com/webtorrent/webtorrent-desktop/pull/1311) ([bnjmnt4n](https://github.com/bnjmnt4n))
- Fix Linux desktop file [\#1309](https://github.com/webtorrent/webtorrent-desktop/pull/1309) ([bilelmoussaoui](https://github.com/bilelmoussaoui))
## v0.20.0 - 2018-04-26
### Added
- Added support for additional audio extensions: 'aiff', 'ape', 'mp2', 'oga', 'opus', 'wma' (#1240)
### Changed
- Displaying filename while music metadata is being downloaded (#1361)
- Improved the poster selection for audio/music based torrents (#1334)
- Launch VLC player without the `--video-on-top` flag (#1286)
### Fixed
- Fix silently failing to open magnets links on Linux (#1367)
## v0.19.0 - 2018-01-26
### Added
- Added watch folder feature: Automatically add new torrent files added to a folder on disk (#1154)
- Added highest playback priority feature: pauses other active torrents when playback starts (#840)
- Add 'Start Speaking' and 'Stop Speaking' menu item (Mac) (#439)
- Add pinch-to-zoom gesture to enter/exit fullscreen (#1148)
### Changed
- [SECURITY] Mitigate Electron protocol handler issue (Windows)
- Moved project from Feross's GitHub account to the WebTorrent GitHub organization
- Updated to electron@1.6.16
- Updated to material-ui@0.17
- Treat .FLAC as playable audio (#1127)
### Fixed
- Fix time and duration so it doesn't bounce in the UI (#1233)
- Fix 'About WebTorrent' menu location on Windows (#1120)
## v0.18.0 - 2017-02-03
### Added ### Added
- Add a new "Transfers" menu for pausing or resuming all torrents (#1027) - Add a new "Transfers" menu for pausing or resuming all torrents (#1027)
@@ -189,7 +67,7 @@
## v0.16.0 - 2016-09-18 ## v0.16.0 - 2016-09-18
### Added ### Added
- **Windows 64-bit support!** (#931) - **Windows 64-bit support!** ([#931](https://github.com/feross/webtorrent-desktop/pull/931))
- Existing 32-bit users will update to 64-bit automatically in next release - Existing 32-bit users will update to 64-bit automatically in next release
- 64-bit reduces likelihood of out-of-memory errors by increasing the address space - 64-bit reduces likelihood of out-of-memory errors by increasing the address space
@@ -638,7 +516,7 @@ Windows, and Linux. For now, we're only releasing binaries for OS X.
WebTorrent Desktop is in ALPHA and under very active development  expect lots more polish in WebTorrent Desktop is in ALPHA and under very active development  expect lots more polish in
the coming weeks! If you know JavaScript and want to help us out, there's the coming weeks! If you know JavaScript and want to help us out, there's
[lots to do](https://github.com/webtorrent/webtorrent-desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)! [lots to do](https://github.com/feross/webtorrent-desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
### Features ### Features

95
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,95 @@
# Contributing Guidelines
Contributions welcome!
**Before spending lots of time on something, ask for feedback on your idea first!**
Please search issues and pull requests before adding something new to avoid duplicating
efforts and conversations.
This project welcomes non-code contributions, too! The following types of contributions
are welcome:
- **Ideas**: participate in an issue thread or start your own to have your voice heard.
- **Writing**: contribute your expertise in an area by helping expand the included docs.
- **Copy editing**: fix typos, clarify language, and improve the quality of the docs.
- **Formatting**: help keep docs easy to read with consistent formatting.
## Code Style
[![standard][standard-image]][standard-url]
This repository uses [`standard`][standard-url] to maintain code style and consistency,
and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have
to!
[standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg
[standard-url]: https://github.com/feross/standard
## Project Governance
Individuals making significant and valuable contributions are given commit-access to the
project to contribute as they see fit. This project is more like an open wiki than a
standard guarded open source project.
### Rules
There are a few basic ground-rules for contributors:
1. **No `--force` pushes** or modifying the Git history in any way.
2. **Non-master branches** should be used for ongoing work.
3. **Significant modifications** like API changes should be subject to a **pull request**
to solicit feedback from other contributors.
4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to
the discretion of the contributor.
### Releases
Declaring formal releases remains the prerogative of the project maintainer.
### Changes to this arrangement
This is an experiment and feedback is welcome! This document may also be subject to pull-
requests or changes by contributors where you believe you have something valuable to add
or change.
## Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
- (a) The contribution was created in whole or in part by me and I have the right to
submit it under the open source license indicated in the file; or
- (b) The contribution is based upon previous work that, to the best of my knowledge, is
covered under an appropriate open source license and I have the right under that license
to submit that work with modifications, whether created in whole or in part by me, under
the same open source license (unless I am permitted to submit under a different
license), as indicated in the file; or
- (c) The contribution was provided directly to me by some other person who certified
(a), (b) or (c) and I have not modified it.
- (d) I understand and agree that this project and the contribution are public and that a
record of the contribution (including all personal information I submit with it,
including my sign-off) is maintained indefinitely and may be redistributed consistent
with this project or the open source license(s) involved.
## Smoke Tests
Before a release, check that the following basic use cases work correctly:
1. Click "Play" to stream a built-in torrent (e.g. Sintel)
- Ensure that seeking to undownloaded region works and plays immediately.
- Ensure that sintel.mp4 gets downloaded to `~/Downloads`.
2. Check that the auto-updater works
- Open the console and check for the line "No update available" to indicate
3. Add a new .torrent file via drag-and-drop.
- Ensure that it gets added to the list and starts downloading
4. Remove a torrent from the client
- Ensure that the file is removed from `~/Downloads`
5. Create and seed a new a torrent via drag-and-drop.
- Ensure that the torrent gets created and seeding begins.

View File

@@ -1,8 +1,6 @@
<h1 align="center"> <h1 align="center">
<br> <br>
<a href="https://webtorrent.io"> <a href="https://webtorrent.io"><img src="https://webtorrent.io/img/WebTorrent.png" alt="WebTorrent" width="200"></a>
<img src="https://webtorrent.io/img/WebTorrent.png" alt="WebTorrent" width="200">
</a>
<br> <br>
WebTorrent Desktop WebTorrent Desktop
<br> <br>
@@ -12,34 +10,20 @@
<h4 align="center">The streaming torrent app. For Mac, Windows, and Linux.</h4> <h4 align="center">The streaming torrent app. For Mac, Windows, and Linux.</h4>
<p align="center"> <p align="center">
<a href="https://discord.gg/cnXkm4Z"><img src="https://img.shields.io/discord/612575111718895616" alt="discord"></a> <a href="https://gitter.im/feross/webtorrent"><img src="https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg" alt="Gitter"></a>
<a href="https://travis-ci.org/webtorrent/webtorrent-desktop"><img src="https://img.shields.io/travis/webtorrent/webtorrent-desktop/master.svg" alt="travis"></a> <a href="https://travis-ci.org/feross/webtorrent-desktop"><img src="https://img.shields.io/travis/feross/webtorrent-desktop/master.svg" alt="Travis"></a>
<a href="https://github.com/webtorrent/webtorrent-desktop/releases"><img src="https://img.shields.io/github/release/webtorrent/webtorrent-desktop.svg" alt="github release version"></a> <a href="https://github.com/feross/webtorrent-desktop/releases"><img src="https://img.shields.io/github/release/feross/webtorrent-desktop.svg" alt="Release"></a>
<a href="https://github.com/webtorrent/webtorrent-desktop/releases"><img src="https://img.shields.io/github/downloads/webtorrent/webtorrent-desktop/total.svg" alt="github release downloads"></a>
<a href="https://standardjs.com"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Standard - JavaScript Style Guide"></a>
</p> </p>
## Install ## Install
### Recommended Install
Download the latest version of WebTorrent Desktop from Download the latest version of WebTorrent Desktop from
[the official website](https://webtorrent.io/desktop/): [the official website](https://webtorrent.io/desktop/) or the
[GitHub releases](https://github.com/feross/webtorrent-desktop/releases) page.
### [✨ Download WebTorrent Desktop ✨](https://webtorrent.io/desktop/) **WebTorrent Desktop** is under very active development. You can try out the
current (unstable) development version by cloning the Git repo. See the
### Advanced Install instructions below in the ["How to Contribute"](#how-to-contribute) section.
- Download specific installer files from the [GitHub releases](https://github.com/webtorrent/webtorrent-desktop/releases) page.
- Use [Homebrew-Cask](https://github.com/caskroom/homebrew-cask) to install from the command line:
```
$ brew install --cask webtorrent
```
- Try the (unstable) development version by cloning the Git repository. See the
["How to Contribute"](#how-to-contribute) instructions.
## Screenshots ## Screenshots
@@ -53,7 +37,7 @@ Download the latest version of WebTorrent Desktop from
### Get the code ### Get the code
``` ```
$ git clone https://github.com/webtorrent/webtorrent-desktop.git $ git clone https://github.com/feross/webtorrent-desktop.git
$ cd webtorrent-desktop $ cd webtorrent-desktop
$ npm install $ npm install
``` ```
@@ -84,25 +68,19 @@ $ npm test
$ npm run test-integration $ npm run test-integration
``` ```
The integration tests use Spectron and Tape. They click through the app, taking screenshots and The integration tests use Spectron and Tape. They click through the app, taking screenshots and comparing each one to a reference. Why screenshots?
comparing each one to a reference. Why screenshots?
* Ad-hoc checking makes the tests a lot more work to write * Ad-hoc checking makes the tests a lot more work to write
* Even diffing the whole HTML is not as thorough as screenshot diffing. For example, it wouldn't * Even diffing the whole HTML is not as thorough as screenshot diffing. For example, it wouldn't catch an bug where hitting ESC from a video doesn't correctly restore window size.
catch an bug where hitting ESC from a video doesn't correctly restore window size.
* Chrome's own integration tests use screenshot diffing iirc * Chrome's own integration tests use screenshot diffing iirc
* Small UI changes will break a few tests, but the fix is as easy as deleting the offending * Small UI changes will break a few tests, but the fix is as easy as deleting the offending screenshots and running the tests, which will recreate them with the new look.
screenshots and running the tests, which will recreate them with the new look. * The resulting Github PR will then show, pixel by pixel, the exact UI changes that were made! Ses https://github.com/blog/817-behold-image-view-modes
* The resulting Github PR will then show, pixel by pixel, the exact UI changes that were made! See
https://github.com/blog/817-behold-image-view-modes
For MacOS, you'll need a Retina screen for the integration tests to pass. Your screen should have For MacOS, you'll need a Retina screen for the integration tests to pass. Your screen should have the same resolution as a 2016 12" Macbook.
the same resolution as a 2018 MacBook Pro 13".
For Windows, you'll need Windows 10 with a 1366x768 screen. For Windows, you'll need Windows 10 with a 1366x768 screen.
When running integration tests, keep the mouse on the edge of the screen and don't touch the mouse When running integration tests, keep the mouse on the edge of the screen and don't touch the mouse or keyboard while the tests are running.
or keyboard while the tests are running.
### Package the app ### Package the app
@@ -125,7 +103,6 @@ The following optional arguments are available:
- `--sign` - Sign the application (Mac, Windows) - `--sign` - Sign the application (Mac, Windows)
- `--package=[type]` - Package single output type. - `--package=[type]` - Package single output type.
- `deb` - Debian package - `deb` - Debian package
- `rpm` - RedHat package
- `zip` - Linux zip file - `zip` - Linux zip file
- `dmg` - Mac disk image - `dmg` - Mac disk image
- `exe` - Windows installer - `exe` - Windows installer
@@ -133,7 +110,7 @@ The following optional arguments are available:
- `all` - All platforms (default) - `all` - All platforms (default)
Note: Even with the `--package` option, the auto-update files (.nupkg for Windows, Note: Even with the `--package` option, the auto-update files (.nupkg for Windows,
-darwin.zip for Mac) will always be produced. *-darwin.zip for Mac) will always be produced.
#### Windows build notes #### Windows build notes
@@ -142,12 +119,11 @@ The Windows app can be packaged from **any** platform.
Note: Windows code signing only works from **Windows**, for now. Note: Windows code signing only works from **Windows**, for now.
Note: To package the Windows app from non-Windows platforms, Note: To package the Windows app from non-Windows platforms,
[Wine](https://www.winehq.org/) and [Mono](https://www.mono-project.com/) need [Wine](https://www.winehq.org/) needs to be installed. For example on Mac, first
to be installed. For example on Mac, first install install [XQuartz](http://www.xquartz.org/), then run:
[XQuartz](http://www.xquartz.org/), then run:
``` ```
brew install wine mono brew install wine
``` ```
(Requires the [Homebrew](http://brew.sh/) package manager.) (Requires the [Homebrew](http://brew.sh/) package manager.)
@@ -160,22 +136,6 @@ The Mac app can only be packaged from **macOS**.
The Linux app can be packaged from **any** platform. The Linux app can be packaged from **any** platform.
If packaging from Mac, install system dependencies with Homebrew by running:
```
npm run install-system-deps
```
#### Recommended readings to start working in the app
Electron (Framework to make native apps for Windows, OSX and Linux in Javascript):
https://electronjs.org/docs/tutorial/quick-start
React.js (Framework to work with Frontend UI):
https://reactjs.org/docs/getting-started.html
Material UI (React components that implement Google's Material Design.):
https://material-ui.com/getting-started/installation
### Privacy ### Privacy
WebTorrent Desktop collects some basic usage stats to help us make the app better. WebTorrent Desktop collects some basic usage stats to help us make the app better.
@@ -185,6 +145,10 @@ Time out? Show a missing codec error?
The app never sends any personally identifying information, nor does it track which The app never sends any personally identifying information, nor does it track which
torrents you add. torrents you add.
### Code Style
[![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard)
## License ## License
MIT. Copyright (c) [WebTorrent, LLC](https://webtorrent.io). MIT. Copyright (c) [WebTorrent, LLC](https://webtorrent.io).

View File

@@ -1,109 +0,0 @@
## Release Process
### 1. Create a new version
- Update `AUTHORS`
```
npm run update-authors
```
Commit if necessary. The commit message should be "authors".
- Write the changelog
You can use `git log --oneline <last version tag>..HEAD` to get a list of changes.
Summarize them concisely in `CHANGELOG.md`. The commit message should be "changelog".
- Update the version
```
npm version [major|minor|patch]
```
This creates both a commit and a git tag.
- Make a PR
Once the PR is reviewed, merge it:
```
git push origin <branch-name>:master
```
This makes it so that the commit hash on master matches the commit hash of the version tag.
Finally, run:
```
git push --tags
```
### 2. Create the release binaries
- On a Mac:
```
npm run package -- darwin --sign
```
Move the `.zip` and `.dmg` file somewhere because the next step wipes the `dist/` folder away.
```
npm run package -- linux --sign
```
- On Windows, or in a Windows VM:
```
npm run package -- win32 --sign
```
- Then, upload the release binaries to Github:
```
npm run gh-release
```
Follow the URL to a newly created Github release page. Manually upload the binaries from
`webtorrent-desktop/dist/`. Open the previous release in another tab, and make sure that you
are uploading the same set of files, no more, no less.
### 3. Test it
**This is the most important part.**
- Manually download the binaries for each platform from Github.
**Do not use your locally built binaries.** Modern OSs treat executables differently if they've
been downloaded, even though the files are byte for byte identical. This ensures that the
codesigning worked and is valid.
- Smoke test WebTorrent Desktop on each platform. 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 that the auto-updater is working. (If the auto updater does not run, users will successfully auto update to this new version, and then be stuck there forever.)
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.
### 4. Ship it
- Update the website
Create a pull request in [webtorrent.io](https://github.com/webtorrent/webtorrent.io). Update
`config.js`, updating the desktop app version.
Once this PR is merged and Feross redeploys the WebTorrent website,
hundreds of thousands of users around the world will start auto updating. **Merge with care.**

View File

@@ -25,7 +25,7 @@ let tmpPath
try { try {
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent') tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
} catch (err) { } catch (err) {
tmpPath = path.join(os.tmpdir(), 'webtorrent') tmpPath = path.join(os.tmpDir(), 'webtorrent')
} }
rimraf.sync(tmpPath) rimraf.sync(tmpPath)

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
</dict>
</plist>

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

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

View File

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

View File

@@ -8,6 +8,7 @@ const cp = require('child_process')
const electronPackager = require('electron-packager') const electronPackager = require('electron-packager')
const fs = require('fs') const fs = require('fs')
const minimist = require('minimist') const minimist = require('minimist')
const mkdirp = require('mkdirp')
const os = require('os') const os = require('os')
const path = require('path') const path = require('path')
const rimraf = require('rimraf') const rimraf = require('rimraf')
@@ -20,7 +21,6 @@ const pkg = require('../package.json')
const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
const BUILD_PATH = path.join(config.ROOT_PATH, 'build') const BUILD_PATH = path.join(config.ROOT_PATH, 'build')
const DIST_PATH = path.join(config.ROOT_PATH, 'dist') const DIST_PATH = path.join(config.ROOT_PATH, 'dist')
const NODE_MODULES_PATH = path.join(config.ROOT_PATH, 'node_modules')
const argv = minimist(process.argv.slice(2), { const argv = minimist(process.argv.slice(2), {
boolean: [ boolean: [
@@ -36,11 +36,6 @@ const argv = minimist(process.argv.slice(2), {
}) })
function build () { function build () {
console.log('Installing node_modules...')
rimraf.sync(NODE_MODULES_PATH)
cp.execSync('npm ci', { stdio: 'inherit' })
console.log('Nuking dist/ and build/...')
rimraf.sync(DIST_PATH) rimraf.sync(DIST_PATH)
rimraf.sync(BUILD_PATH) rimraf.sync(BUILD_PATH)
@@ -69,11 +64,11 @@ function build () {
const all = { const all = {
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata // The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
// property on Windows, and `NSHumanReadableCopyright` on Mac. // property on Windows, and `NSHumanReadableCopyright` on Mac.
appCopyright: config.APP_COPYRIGHT, 'app-copyright': config.APP_COPYRIGHT,
// The release version of the application. Maps to the `ProductVersion` metadata // The release version of the application. Maps to the `ProductVersion` metadata
// property on Windows, and `CFBundleShortVersionString` on Mac. // property on Windows, and `CFBundleShortVersionString` on Mac.
appVersion: pkg.version, 'app-version': pkg.version,
// Package the application's source code into an archive, using Electron's archive // Package the application's source code into an archive, using Electron's archive
// format. Mitigates issues around long path names on Windows and slightly speeds up // format. Mitigates issues around long path names on Windows and slightly speeds up
@@ -87,7 +82,7 @@ const all = {
// The build version of the application. Maps to the FileVersion metadata property on // The build version of the application. Maps to the FileVersion metadata property on
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to // Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
// start with a number. We're using the version of the underlying WebTorrent library. // start with a number. We're using the version of the underlying WebTorrent library.
buildVersion: require('webtorrent/package.json').version, 'build-version': require('webtorrent/package.json').version,
// The application source directory. // The application source directory.
dir: config.ROOT_PATH, dir: config.ROOT_PATH,
@@ -117,18 +112,18 @@ const darwin = {
// Build for Mac // Build for Mac
platform: 'darwin', platform: 'darwin',
// Build x64 binary only. // Build x64 binaries only.
arch: 'x64', arch: 'x64',
// The bundle identifier to use in the application's plist (Mac only). // The bundle identifier to use in the application's plist (Mac only).
appBundleId: 'io.webtorrent.webtorrent', 'app-bundle-id': 'io.webtorrent.webtorrent',
// The application category type, as shown in the Finder via "View" -> "Arrange by // The application category type, as shown in the Finder via "View" -> "Arrange by
// Application Category" when viewing the Applications directory (Mac only). // Application Category" when viewing the Applications directory (Mac only).
appCategoryType: 'public.app-category.utilities', 'app-category-type': 'public.app-category.utilities',
// The bundle identifier to use in the application helper's plist (Mac only). // The bundle identifier to use in the application helper's plist (Mac only).
helperBundleId: 'io.webtorrent.webtorrent-helper', 'helper-bundle-id': 'io.webtorrent.webtorrent-helper',
// Application icon. // Application icon.
icon: config.APP_ICON + '.icns' icon: config.APP_ICON + '.icns'
@@ -138,8 +133,8 @@ const win32 = {
// Build for Windows. // Build for Windows.
platform: 'win32', platform: 'win32',
// Build x64 binary only. // Build ia32 and x64 binaries.
arch: 'x64', arch: ['ia32', 'x64'],
// Object hash of application metadata to embed into the executable (Windows only) // Object hash of application metadata to embed into the executable (Windows only)
win32metadata: { win32metadata: {
@@ -172,8 +167,8 @@ const linux = {
// Build for Linux. // Build for Linux.
platform: 'linux', platform: 'linux',
// Build x64, armv7l, and arm64 binaries. // Build ia32 and x64 binaries.
arch: ['x64', 'armv7l', 'arm64'] arch: ['ia32', 'x64']
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option. // Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
} }
@@ -184,7 +179,8 @@ function buildDarwin (cb) {
const plist = require('plist') const plist = require('plist')
console.log('Mac: Packaging electron...') console.log('Mac: Packaging electron...')
electronPackager(Object.assign({}, all, darwin)).then(function (buildPath) { electronPackager(Object.assign({}, all, darwin), function (err, buildPath) {
if (err) return cb(err)
console.log('Mac: Packaged electron. ' + buildPath) console.log('Mac: Packaged electron. ' + buildPath)
const appPath = path.join(buildPath[0], config.APP_NAME + '.app') const appPath = path.join(buildPath[0], config.APP_NAME + '.app')
@@ -195,16 +191,16 @@ function buildDarwin (cb) {
infoPlist.CFBundleDocumentTypes = [ infoPlist.CFBundleDocumentTypes = [
{ {
CFBundleTypeExtensions: ['torrent'], CFBundleTypeExtensions: [ 'torrent' ],
CFBundleTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns', CFBundleTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
CFBundleTypeName: 'BitTorrent Document', CFBundleTypeName: 'BitTorrent Document',
CFBundleTypeRole: 'Editor', CFBundleTypeRole: 'Editor',
LSHandlerRank: 'Owner', LSHandlerRank: 'Owner',
LSItemContentTypes: ['org.bittorrent.torrent'] LSItemContentTypes: [ 'org.bittorrent.torrent' ]
}, },
{ {
CFBundleTypeName: 'Any', CFBundleTypeName: 'Any',
CFBundleTypeOSTypes: ['****'], CFBundleTypeOSTypes: [ '****' ],
CFBundleTypeRole: 'Editor', CFBundleTypeRole: 'Editor',
LSHandlerRank: 'Owner', LSHandlerRank: 'Owner',
LSTypeIsPackage: false LSTypeIsPackage: false
@@ -216,13 +212,13 @@ function buildDarwin (cb) {
CFBundleTypeRole: 'Editor', CFBundleTypeRole: 'Editor',
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns', CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
CFBundleURLName: 'BitTorrent Magnet URL', CFBundleURLName: 'BitTorrent Magnet URL',
CFBundleURLSchemes: ['magnet'] CFBundleURLSchemes: [ 'magnet' ]
}, },
{ {
CFBundleTypeRole: 'Editor', CFBundleTypeRole: 'Editor',
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns', CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
CFBundleURLName: 'BitTorrent Stream-Magnet URL', CFBundleURLName: 'BitTorrent Stream-Magnet URL',
CFBundleURLSchemes: ['stream-magnet'] CFBundleURLSchemes: [ 'stream-magnet' ]
} }
] ]
@@ -239,7 +235,7 @@ function buildDarwin (cb) {
UTTypeReferenceURL: 'http://www.bittorrent.org/beps/bep_0000.html', UTTypeReferenceURL: 'http://www.bittorrent.org/beps/bep_0000.html',
UTTypeTagSpecification: { UTTypeTagSpecification: {
'com.apple.ostype': 'TORR', 'com.apple.ostype': 'TORR',
'public.filename-extension': ['torrent'], 'public.filename-extension': [ 'torrent' ],
'public.mime-type': 'application/x-bittorrent' 'public.mime-type': 'application/x-bittorrent'
} }
} }
@@ -266,7 +262,6 @@ function buildDarwin (cb) {
function signApp (cb) { function signApp (cb) {
const sign = require('electron-osx-sign') const sign = require('electron-osx-sign')
const { notarize } = require('electron-notarize')
/* /*
* Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons: * Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons:
@@ -282,37 +277,16 @@ function buildDarwin (cb) {
* - Membership in the Apple Developer Program * - Membership in the Apple Developer Program
*/ */
const signOpts = { const signOpts = {
verbose: true,
app: appPath, app: appPath,
platform: 'darwin', platform: 'darwin',
identity: 'Developer ID Application: WebTorrent, LLC (5MAMC8G3L8)', verbose: true
hardenedRuntime: true,
entitlements: path.join(config.ROOT_PATH, 'bin', 'darwin-entitlements.plist'),
'entitlements-inherit': path.join(config.ROOT_PATH, 'bin', 'darwin-entitlements.plist'),
'signature-flags': 'library'
}
const notarizeOpts = {
appBundleId: darwin.appBundleId,
appPath,
appleId: 'feross@feross.org',
appleIdPassword: '@keychain:AC_PASSWORD'
} }
console.log('Mac: Signing app...') console.log('Mac: Signing app...')
sign(signOpts, function (err) { sign(signOpts, function (err) {
if (err) return cb(err) if (err) return cb(err)
console.log('Mac: Signed app.') console.log('Mac: Signed app.')
cb(null)
console.log('Mac: Notarizing app...')
notarize(notarizeOpts).then(
function () {
console.log('Mac: Notarized app.')
cb(null)
},
function (err) {
cb(err)
})
}) })
} }
@@ -375,8 +349,6 @@ function buildDarwin (cb) {
cb(null) cb(null)
}) })
} }
}).catch(function (err) {
cb(err)
}) })
} }
@@ -397,7 +369,8 @@ function buildWin32 (cb) {
CERT_PATH = path.join(os.homedir(), 'Desktop') CERT_PATH = path.join(os.homedir(), 'Desktop')
} }
electronPackager(Object.assign({}, all, win32)).then(function (buildPath) { electronPackager(Object.assign({}, all, win32), function (err, buildPath) {
if (err) return cb(err)
console.log('Windows: Packaged electron. ' + buildPath) console.log('Windows: Packaged electron. ' + buildPath)
let signWithParams let signWithParams
@@ -416,17 +389,21 @@ function buildWin32 (cb) {
const tasks = [] const tasks = []
buildPath.forEach(function (filesPath) { buildPath.forEach(function (filesPath) {
const destArch = filesPath.split('-').pop()
if (argv.package === 'exe' || argv.package === 'all') { if (argv.package === 'exe' || argv.package === 'all') {
tasks.push((cb) => packageInstaller(filesPath, cb)) tasks.push((cb) => packageInstaller(filesPath, destArch, cb))
} }
if (argv.package === 'portable' || argv.package === 'all') { if (argv.package === 'portable' || argv.package === 'all') {
tasks.push((cb) => packagePortable(filesPath, cb)) tasks.push((cb) => packagePortable(filesPath, destArch, cb))
} }
}) })
series(tasks, cb) series(tasks, cb)
function packageInstaller (filesPath, cb) { function packageInstaller (filesPath, destArch, cb) {
console.log('Windows: Creating installer...') console.log(`Windows: Creating ${destArch} installer...`)
const archStr = destArch === 'ia32' ? '-ia32' : ''
installer.createWindowsInstaller({ installer.createWindowsInstaller({
appDirectory: filesPath, appDirectory: filesPath,
@@ -439,69 +416,107 @@ function buildWin32 (cb) {
noMsi: true, noMsi: true,
outputDirectory: DIST_PATH, outputDirectory: DIST_PATH,
productName: config.APP_NAME, productName: config.APP_NAME,
/**
* Only create delta updates for the Windows x64 build because 90% of our
* users have Windows x64 and the delta files take a *very* long time to
* generate. Also, the ia32 files on GitHub have non-standard Squirrel
* names (i.e. RELEASES-ia32 instead of RELEASES) and so Squirrel won't
* find them unless we proxy the requests.
*/
// TODO: Re-enable Windows 64-bit delta updates when we confirm that they // TODO: Re-enable Windows 64-bit delta updates when we confirm that they
// work correctly in the presence of the "ia32" .nupkg files. I // work correctly in the presence of the "ia32" .nupkg files. I
// (feross) noticed them listed in the 64-bit RELEASES file and // (feross) noticed them listed in the 64-bit RELEASES file and
// manually edited them out for the v0.17 release. Shipping only // manually edited them out for the v0.17 release. Shipping only
// full updates for now will work fine, with no ill-effects. // full updates for now will work fine, with no ill-effects.
// remoteReleases: config.GITHUB_URL, // remoteReleases: destArch === 'x64'
// ? config.GITHUB_URL
// : undefined,
/** /**
* If you hit a "GitHub API rate limit exceeded" error, set this token! * If you hit a "GitHub API rate limit exceeded" error, set this token!
*/ */
// remoteToken: process.env.WEBTORRENT_GITHUB_API_TOKEN, // remoteToken: process.env.WEBTORRENT_GITHUB_API_TOKEN,
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe', setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + archStr + '.exe',
setupIcon: config.APP_ICON + '.ico', setupIcon: config.APP_ICON + '.ico',
signWithParams, signWithParams: signWithParams,
title: config.APP_NAME, title: config.APP_NAME,
usePackageJson: false, usePackageJson: false,
version: pkg.version version: pkg.version
}) })
.then(function () { .then(function () {
console.log('Windows: Created installer.') console.log(`Windows: Created ${destArch} installer.`)
/** /**
* Delete extraneous Squirrel files (i.e. *.nupkg delta files for older * Delete extraneous Squirrel files (i.e. *.nupkg delta files for older
* versions of the app) * versions of the app)
*/ */
fs.readdirSync(DIST_PATH) fs.readdirSync(DIST_PATH)
.filter((name) => name.endsWith('.nupkg') && !name.includes(pkg.version)) .filter((name) => name.endsWith('.nupkg') && !name.includes(pkg.version))
.forEach((filename) => { .forEach((filename) => {
fs.unlinkSync(path.join(DIST_PATH, filename)) fs.unlinkSync(path.join(DIST_PATH, filename))
}) })
cb(null) if (destArch === 'ia32') {
}) console.log('Windows: Renaming ia32 installer files...')
.catch(cb)
// RELEASES -> RELEASES-ia32
const relPath = path.join(DIST_PATH, 'RELEASES-ia32')
fs.renameSync(
path.join(DIST_PATH, 'RELEASES'),
relPath
)
// WebTorrent-vX.X.X-full.nupkg -> WebTorrent-vX.X.X-ia32-full.nupkg
fs.renameSync(
path.join(DIST_PATH, `${config.APP_NAME}-${config.APP_VERSION}-full.nupkg`),
path.join(DIST_PATH, `${config.APP_NAME}-${config.APP_VERSION}-ia32-full.nupkg`)
)
// Change file name inside RELEASES-ia32 to match renamed file
const relContent = fs.readFileSync(relPath, 'utf8')
const relContent32 = relContent.replace('full.nupkg', 'ia32-full.nupkg')
fs.writeFileSync(relPath, relContent32)
if (relContent === relContent32) {
// Sanity check
throw new Error('Fixing RELEASES-ia32 failed. Replacement did not modify the file.')
}
console.log('Windows: Renamed ia32 installer files.')
}
cb(null)
})
.catch(cb)
} }
function packagePortable (filesPath, cb) { function packagePortable (filesPath, destArch, cb) {
console.log('Windows: Creating portable app...') console.log(`Windows: Creating ${destArch} portable app...`)
const portablePath = path.join(filesPath, 'Portable Settings') const portablePath = path.join(filesPath, 'Portable Settings')
fs.mkdirSync(portablePath, { recursive: true }) mkdirp.sync(portablePath)
const downloadsPath = path.join(portablePath, 'Downloads') const downloadsPath = path.join(portablePath, 'Downloads')
fs.mkdirSync(downloadsPath, { recursive: true }) mkdirp.sync(downloadsPath)
const tempPath = path.join(portablePath, 'Temp') const tempPath = path.join(portablePath, 'Temp')
fs.mkdirSync(tempPath, { recursive: true }) mkdirp.sync(tempPath)
const archStr = destArch === 'ia32' ? '-ia32' : ''
const inPath = path.join(DIST_PATH, path.basename(filesPath)) const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip') const outPath = path.join(DIST_PATH, BUILD_NAME + '-win' + archStr + '.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log('Windows: Created portable app.') console.log(`Windows: Created ${destArch} portable app.`)
cb(null) cb(null)
} }
}).catch(function (err) {
cb(err)
}) })
} }
function buildLinux (cb) { function buildLinux (cb) {
console.log('Linux: Packaging electron...') console.log('Linux: Packaging electron...')
electronPackager(Object.assign({}, all, linux), function (err, buildPath) {
electronPackager(Object.assign({}, all, linux)).then(function (buildPath) { if (err) return cb(err)
console.log('Linux: Packaged electron. ' + buildPath) console.log('Linux: Packaged electron. ' + buildPath)
const tasks = [] const tasks = []
@@ -511,102 +526,56 @@ function buildLinux (cb) {
if (argv.package === 'deb' || argv.package === 'all') { if (argv.package === 'deb' || argv.package === 'all') {
tasks.push((cb) => packageDeb(filesPath, destArch, cb)) tasks.push((cb) => packageDeb(filesPath, destArch, cb))
} }
if (argv.package === 'rpm' || argv.package === 'all') {
tasks.push((cb) => packageRpm(filesPath, destArch, cb))
}
if (argv.package === 'zip' || argv.package === 'all') { if (argv.package === 'zip' || argv.package === 'all') {
tasks.push((cb) => packageZip(filesPath, destArch, cb)) tasks.push((cb) => packageZip(filesPath, destArch, cb))
} }
}) })
series(tasks, cb) series(tasks, cb)
}).catch(function (err) {
cb(err)
}) })
function packageDeb (filesPath, destArch, cb) { function packageDeb (filesPath, destArch, cb) {
// Linux convention for Debian based 'x64' is 'amd64'
if (destArch === 'x64') {
destArch = 'amd64'
}
// Create .deb file for Debian-based platforms // Create .deb file for Debian-based platforms
console.log(`Linux: Creating ${destArch} deb...`) console.log(`Linux: Creating ${destArch} deb...`)
const installer = require('electron-installer-debian') const deb = require('nobin-debian-installer')()
const destPath = path.join('/opt', pkg.name)
const options = { deb.pack({
src: filesPath + '/', package: pkg,
dest: DIST_PATH, info: {
arch: destArch, arch: destArch === 'x64' ? 'amd64' : 'i386',
bin: 'WebTorrent', targetDir: DIST_PATH,
icon: { depends: 'gconf2, libgtk2.0-0, libnss3, libxss1',
'48x48': path.join(config.STATIC_PATH, 'linux/share/icons/hicolor/48x48/apps/webtorrent-desktop.png'), scripts: {
'256x256': path.join(config.STATIC_PATH, 'linux/share/icons/hicolor/256x256/apps/webtorrent-desktop.png') postinst: path.join(config.STATIC_PATH, 'linux', 'postinst'),
}, prerm: path.join(config.STATIC_PATH, 'linux', 'prerm')
categories: ['Network', 'FileTransfer', 'P2P'], }
mimeType: ['application/x-bittorrent', 'x-scheme-handler/magnet', 'x-scheme-handler/stream-magnet'], }
desktopTemplate: path.join(config.STATIC_PATH, 'linux/webtorrent-desktop.ejs'), }, [{
lintianOverrides: [ src: ['./**'],
'unstripped-binary-or-object', dest: destPath,
'embedded-library', expand: true,
'missing-dependency-on-libc', cwd: filesPath
'changelog-file-missing-in-native-package', }, {
'description-synopsis-is-duplicated', src: ['./**'],
'setuid-binary', dest: path.join('/usr', 'share'),
'binary-without-manpage', expand: true,
'shlib-with-executable-bit' cwd: path.join(config.STATIC_PATH, 'linux', 'share')
] }], function (err) {
} if (err) return cb(err)
console.log(`Linux: Created ${destArch} deb.`)
installer(options).then( cb(null)
() => { })
console.log(`Linux: Created ${destArch} deb.`)
cb(null)
},
(err) => cb(err)
)
}
function packageRpm (filesPath, destArch, cb) {
// Linux convention for RedHat based 'x64' is 'x86_64'
if (destArch === 'x64') {
destArch = 'x86_64'
}
// Create .rpm file for RedHat-based platforms
console.log(`Linux: Creating ${destArch} rpm...`)
const installer = require('electron-installer-redhat')
const options = {
src: filesPath + '/',
dest: DIST_PATH,
arch: destArch,
bin: 'WebTorrent',
icon: {
'48x48': path.join(config.STATIC_PATH, 'linux/share/icons/hicolor/48x48/apps/webtorrent-desktop.png'),
'256x256': path.join(config.STATIC_PATH, 'linux/share/icons/hicolor/256x256/apps/webtorrent-desktop.png')
},
categories: ['Network', 'FileTransfer', 'P2P'],
mimeType: ['application/x-bittorrent', 'x-scheme-handler/magnet', 'x-scheme-handler/stream-magnet'],
desktopTemplate: path.join(config.STATIC_PATH, 'linux/webtorrent-desktop.ejs')
}
installer(options).then(
() => {
console.log(`Linux: Created ${destArch} rpm.`)
cb(null)
},
(err) => cb(err)
)
} }
function packageZip (filesPath, destArch, cb) { function packageZip (filesPath, destArch, cb) {
// Create .zip file for Linux // Create .zip file for Linux
console.log(`Linux: Creating ${destArch} zip...`) console.log(`Linux: Creating ${destArch} zip...`)
const archStr = destArch === 'ia32' ? '-ia32' : ''
const inPath = path.join(DIST_PATH, path.basename(filesPath)) const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, `${BUILD_NAME}-linux-${destArch}.zip`) const outPath = path.join(DIST_PATH, BUILD_NAME + '-linux' + archStr + '.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log(`Linux: Created ${destArch} zip.`) console.log(`Linux: Created ${destArch} zip.`)

9
bin/release-_post.sh Executable file
View File

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

8
bin/release-_pre.sh Executable file
View File

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

7
bin/release-major.sh Executable file
View File

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

7
bin/release-minor.sh Executable file
View File

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

7
bin/release-patch.sh Executable file
View File

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

View File

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

View File

@@ -1 +0,0 @@
module.exports = require('electron')

View File

@@ -1 +0,0 @@
import('./build/main/index.js')

1
index.js Normal file
View File

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

25918
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +1,77 @@
{ {
"name": "webtorrent-desktop", "name": "webtorrent-desktop",
"type": "module",
"description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.", "description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.",
"version": "0.24.0", "version": "0.18.0",
"author": { "author": {
"name": "WebTorrent, LLC", "name": "WebTorrent, LLC",
"email": "feross@webtorrent.io", "email": "feross@webtorrent.io",
"url": "https://webtorrent.io" "url": "https://webtorrent.io"
}, },
"babel": {
"plugins": [
[
"@babel/plugin-transform-react-jsx",
{
"useBuiltIns": true
}
]
]
},
"bugs": { "bugs": {
"url": "https://github.com/webtorrent/webtorrent-desktop/issues" "url": "https://github.com/feross/webtorrent-desktop/issues"
}, },
"dependencies": { "dependencies": {
"@electron/remote": "2.0.1", "airplayer": "^2.0.0",
"airplayer": "github:webtorrent/airplayer#fix-security", "application-config": "^1.0.0",
"application-config": "2.0.0", "arch": "^2.0.0",
"arch": "2.2.0", "auto-launch": "^4.0.1",
"auto-launch": "5.0.5", "bitfield": "^1.0.2",
"bitfield": "4.0.0", "capture-frame": "^1.0.0",
"capture-frame": "4.0.0", "chromecasts": "^1.8.0",
"chokidar": "3.5.2", "cp-file": "^4.0.1",
"chromecasts": "1.10.2", "create-torrent": "^3.24.5",
"create-torrent": "5.0.1", "debounce": "^1.0.0",
"debounce": "1.2.1", "deep-equal": "^1.0.1",
"dlnacasts": "0.1.0", "dlnacasts": "^0.1.0",
"drag-drop": "7.2.0", "drag-drop": "^2.12.1",
"es6-error": "4.1.1", "es6-error": "^4.0.0",
"fn-getter": "1.0.0", "fn-getter": "^1.0.0",
"iso-639-1": "2.1.9", "iso-639-1": "^1.2.1",
"languagedetect": "2.0.0", "languagedetect": "^1.1.1",
"location-history": "1.1.2", "location-history": "^1.0.0",
"material-ui": "0.20.2", "material-ui": "^0.16.0",
"music-metadata": "7.11.4", "mkdirp": "^0.5.1",
"network-address": "1.1.2", "musicmetadata": "^2.0.2",
"parse-torrent": "9.1.4", "network-address": "^1.1.0",
"prettier-bytes": "1.0.4", "parse-torrent": "^5.7.3",
"prop-types": "15.7.2", "prettier-bytes": "^1.0.1",
"react": "17.0.2", "react": "^15.2.1",
"react-dom": "17.0.2", "react-dom": "^15.2.1",
"rimraf": "3.0.2", "react-tap-event-plugin": "^2.0.1",
"run-parallel": "1.2.0", "rimraf": "^2.5.2",
"semver": "7.3.5", "run-parallel": "^1.1.6",
"simple-concat": "1.0.1", "semver": "^5.1.0",
"simple-get": "4.0.0", "simple-concat": "^1.0.0",
"srt-to-vtt": "1.1.3", "simple-get": "^2.0.0",
"vlc-command": "1.2.0", "srt-to-vtt": "^1.1.1",
"webtorrent": "1.5.6", "vlc-command": "^1.0.1",
"winreg": "1.2.4" "webtorrent": "0.x",
"winreg": "^1.2.0",
"zero-fill": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.15.7", "buble": "^0.15.2",
"@babel/core": "7.15.8", "cross-zip": "^2.0.1",
"@babel/eslint-parser": "7.15.8", "depcheck": "^0.6.4",
"@babel/plugin-transform-react-jsx": "7.14.9", "electron": "1.4.15",
"cross-zip": "4.0.0", "electron-osx-sign": "0.4.3",
"depcheck": "1.4.2", "electron-packager": "~8.5.1",
"electron": "15.2.0", "electron-winstaller": "~2.5.2",
"electron-notarize": "1.1.1", "gh-release": "^2.0.3",
"electron-osx-sign": "0.5.0", "minimist": "^1.2.0",
"electron-packager": "15.4.0", "nobin-debian-installer": "0.0.10",
"electron-winstaller": "5.0.0", "nodemon": "^1.10.2",
"gh-release": "6.0.1", "open": "0.0.5",
"minimist": "1.2.5", "plist": "^2.0.1",
"nodemon": "2.0.13", "pngjs": "^3.0.0",
"open": "8.3.0", "run-series": "^1.1.4",
"plist": "3.0.4", "spectron": "^3.3.0",
"pngjs": "6.0.0", "standard": "*",
"run-series": "1.1.9", "tape": "^4.6.0",
"spectron": "15.0.0", "walk-sync": "^0.3.1"
"standard": "16.0.4",
"tape": "5.3.1"
}, },
"engines": { "engines": {
"node": "^16.0.0", "node": ">=4.0.0"
"npm": "^7.10.0 || ^8.0.0"
}, },
"homepage": "https://webtorrent.io", "homepage": "https://webtorrent.io",
"keywords": [ "keywords": [
@@ -97,37 +85,27 @@
"webtorrent" "webtorrent"
], ],
"license": "MIT", "license": "MIT",
"main": "index.cjs", "main": "index.js",
"optionalDependencies": { "optionalDependencies": {
"appdmg": "^0.6.0", "appdmg": "^0.4.3"
"electron-installer-debian": "^3.1.0",
"electron-installer-redhat": "^3.3.0"
}, },
"private": true, "private": true,
"productName": "WebTorrent", "productName": "WebTorrent",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/webtorrent/webtorrent-desktop.git" "url": "git://github.com/feross/webtorrent-desktop.git"
}, },
"scripts": { "scripts": {
"build": "babel src --out-dir build", "build": "buble src --output build",
"clean": "node ./bin/clean.js", "clean": "node ./bin/clean.js",
"gh-release": "gh-release", "gh-release": "gh-release",
"install-system-deps": "brew install fakeroot dpkg rpm",
"open-config": "node ./bin/open-config.js", "open-config": "node ./bin/open-config.js",
"package": "node ./bin/package.js", "package": "node ./bin/package.js",
"start": "npm run build && electron --no-sandbox .", "prepublish": "npm run build",
"test": "standard && depcheck --ignores=standard,@babel/eslint-parser --ignore-dirs=build,dist", "start": "npm run build && electron .",
"test": "standard && depcheck --ignores=buble,nodemon,gh-release --ignore-dirs=build,dist && node ./bin/extra-lint.js",
"test-integration": "npm run build && node ./test", "test-integration": "npm run build && node ./test",
"update-authors": "./bin/update-authors.sh", "update-authors": "./bin/update-authors.sh",
"watch": "nodemon --exec \"npm run start\" --ext js,css --ignore build/ --ignore dist/" "watch": "nodemon --exec \"npm run start\" --ext js,css --ignore build/ --ignore dist/"
},
"standard": {
"parser": "@babel/eslint-parser"
},
"renovate": {
"extends": [
"github>webtorrent/renovate-config"
]
} }
} }

View File

@@ -1,13 +1,12 @@
import applicationConfig from 'application-config' const appConfig = require('application-config')('WebTorrent')
import path from 'path' const path = require('path')
import arch from 'arch' const electron = require('electron')
import fs from 'fs' const arch = require('arch')
import electron from '../electron.cjs'
const appConfig = applicationConfig('WebTorrent')
const APP_NAME = 'WebTorrent' const APP_NAME = 'WebTorrent'
const APP_TEAM = 'WebTorrent, LLC' const APP_TEAM = 'WebTorrent, LLC'
const APP_VERSION = JSON.parse(fs.readFileSync('package.json').toString()).version const APP_VERSION = require('../package.json').version
const IS_TEST = isTest() const IS_TEST = isTest()
const PORTABLE_PATH = IS_TEST const PORTABLE_PATH = IS_TEST
? path.join(process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp', 'WebTorrentTest') ? path.join(process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp', 'WebTorrentTest')
@@ -18,6 +17,91 @@ const IS_PORTABLE = isPortable()
const UI_HEADER_HEIGHT = 38 const UI_HEADER_HEIGHT = 38
const UI_TORRENT_HEIGHT = 100 const UI_TORRENT_HEIGHT = 100
module.exports = {
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
APP_COPYRIGHT: 'Copyright © 2014-2017 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
APP_NAME: APP_NAME,
APP_TEAM: APP_TEAM,
APP_VERSION: APP_VERSION,
APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
CONFIG_PATH: getConfigPath(),
DEFAULT_TORRENTS: [
{
testID: 'bbb',
name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent'
},
{
testID: 'cosmos',
name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent'
},
{
testID: 'sintel',
name: 'Sintel',
posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent'
},
{
testID: 'tears',
name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent'
},
{
testID: 'wired',
name: 'The WIRED CD - Rip. Sample. Mash. Share',
posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent'
}
],
DELAYED_INIT: 3000 /* 3 seconds */,
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
HOME_PAGE_URL: 'https://webtorrent.io',
IS_PORTABLE: IS_PORTABLE,
IS_PRODUCTION: IS_PRODUCTION,
IS_TEST: IS_TEST,
OS_SYSARCH: arch() === 'x64' ? 'x64' : 'ia32',
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
ROOT_PATH: path.join(__dirname, '..'),
STATIC_PATH: path.join(__dirname, '..', 'static'),
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
WINDOW_ABOUT: 'file://' + path.join(__dirname, '..', 'static', 'about.html'),
WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'),
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'),
WINDOW_INITIAL_BOUNDS: {
width: 500,
height: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 6) // header + 6 torrents
},
WINDOW_MIN_HEIGHT: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 2), // header + 2 torrents
WINDOW_MIN_WIDTH: 425,
UI_HEADER_HEIGHT: UI_HEADER_HEIGHT,
UI_TORRENT_HEIGHT: UI_TORRENT_HEIGHT
}
function getConfigPath () { function getConfigPath () {
if (IS_PORTABLE) { if (IS_PORTABLE) {
return PORTABLE_PATH return PORTABLE_PATH
@@ -35,12 +119,16 @@ function getDefaultDownloadPath () {
} }
function getPath (key) { function getPath (key) {
if (!process.versions.electron || process.type !== 'browser') { if (!process.versions.electron) {
// Node.js process // Node.js process
return '' return ''
} else if (process.type === 'renderer') {
// Electron renderer process
return electron.remote.app.getPath(key)
} else {
// Electron main process
return electron.app.getPath(key)
} }
// Electron main process
return electron.app.getPath(key)
} }
function isTest () { function isTest () {
@@ -57,6 +145,8 @@ function isPortable () {
return false return false
} }
const fs = require('fs')
try { try {
// This line throws if the "Portable Settings" folder does not exist, and does // This line throws if the "Portable Settings" folder does not exist, and does
// nothing otherwise. // nothing otherwise.
@@ -82,117 +172,3 @@ function isProduction () {
return !/\/electron$/.test(process.execPath) return !/\/electron$/.test(process.execPath)
} }
} }
export const ANNOUNCEMENT_URL = 'https://webtorrent.io/desktop/announcement'
export const AUTO_UPDATE_URL = 'https://webtorrent.io/desktop/update'
export const CRASH_REPORT_URL = 'https://webtorrent.io/desktop/crash-report'
export const TELEMETRY_URL = 'https://webtorrent.io/desktop/telemetry'
export const APP_COPYRIGHT = `Copyright © 2014-${new Date().getFullYear()} ${APP_TEAM}`
export const APP_FILE_ICON = new URL('../static/WebTorrentFile', import.meta.url).pathname // path.join(__dirname, '..',
// 'static', 'WebTorrentFile')
export const APP_ICON = new URL('../static/WebTorrent', import.meta.url).pathname // path.join(__dirname, '..',
// 'static',
// 'WebTorrent')
export const CONFIG_PATH = getConfigPath()
export const DEFAULT_TORRENTS = [
{
testID: 'bbb',
name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent'
},
{
testID: 'cosmos',
name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent'
},
{
testID: 'sintel',
name: 'Sintel',
posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent'
},
{
testID: 'tears',
name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent'
},
{
testID: 'wired',
name: 'The WIRED CD - Rip. Sample. Mash. Share',
posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent'
}
]
export const DELAYED_INIT = 3000 /* 3 seconds */
export const DEFAULT_DOWNLOAD_PATH = getDefaultDownloadPath()
export const GITHUB_URL = 'https://github.com/webtorrent/webtorrent-desktop'
export const GITHUB_URL_ISSUES = 'https://github.com/webtorrent/webtorrent-desktop/issues'
export const GITHUB_URL_RAW = 'https://raw.githubusercontent.com/webtorrent/webtorrent-desktop/master'
export const GITHUB_URL_RELEASES = 'https://github.com/webtorrent/webtorrent-desktop/releases'
export const HOME_PAGE_URL = 'https://webtorrent.io'
export const TWITTER_PAGE_URL = 'https://twitter.com/WebTorrentApp'
export const OS_SYSARCH = arch() === 'x64' ? 'x64' : 'ia32'
export const POSTER_PATH = path.join(getConfigPath(), 'Posters')
export const ROOT_PATH = new URL('../', import.meta.url).pathname
export const STATIC_PATH = new URL('../static', import.meta.url).pathname
export const TORRENT_PATH = path.join(getConfigPath(), 'Torrents')
export const WINDOW_ABOUT = 'file://' + new URL('../static/about.html', import.meta.url).pathname
export const WINDOW_MAIN = 'file://' + new URL('../static/main.html', import.meta.url).pathname
export const WINDOW_WEBTORRENT = 'file://' + new URL('../static/webtorrent.html', import.meta.url).pathname
export const WINDOW_INITIAL_BOUNDS = {
width: 500,
height: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 6) // header + 6 torrents
}
export const WINDOW_MIN_HEIGHT = UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 2)
export const WINDOW_MIN_WIDTH = 425
export { APP_NAME }
export { APP_TEAM }
export { APP_VERSION }
export { APP_NAME as APP_WINDOW_TITLE }
export { IS_PORTABLE }
export { IS_PRODUCTION }
export { IS_TEST }
export { UI_HEADER_HEIGHT }
export { UI_TORRENT_HEIGHT }
export default {
ANNOUNCEMENT_URL,
AUTO_UPDATE_URL,
CRASH_REPORT_URL,
TELEMETRY_URL,
APP_COPYRIGHT,
APP_FILE_ICON,
APP_ICON,
APP_NAME,
APP_TEAM,
APP_VERSION,
APP_WINDOW_TITLE: APP_NAME,
CONFIG_PATH,
DEFAULT_TORRENTS,
DELAYED_INIT,
DEFAULT_DOWNLOAD_PATH,
GITHUB_URL,
GITHUB_URL_ISSUES,
GITHUB_URL_RAW,
GITHUB_URL_RELEASES,
HOME_PAGE_URL,
TWITTER_PAGE_URL,
IS_PORTABLE,
IS_PRODUCTION,
IS_TEST,
OS_SYSARCH,
POSTER_PATH,
ROOT_PATH,
STATIC_PATH,
TORRENT_PATH,
WINDOW_ABOUT,
WINDOW_MAIN,
WINDOW_WEBTORRENT,
WINDOW_INITIAL_BOUNDS,
WINDOW_MIN_HEIGHT,
WINDOW_MIN_WIDTH,
UI_HEADER_HEIGHT,
UI_TORRENT_HEIGHT
}

View File

@@ -1,13 +1,14 @@
import electron from '../electron.cjs' module.exports = {
init
async function init () {
const config = await import('./config.js')
electron.crashReporter.start({
productName: config.APP_NAME,
submitURL: config.CRASH_REPORT_URL,
globalExtra: { _companyName: config.APP_NAME },
compress: true
})
} }
export default { init } function init () {
const config = require('./config')
const electron = require('electron')
electron.crashReporter.start({
companyName: config.APP_NAME,
productName: config.APP_NAME,
submitURL: config.CRASH_REPORT_URL
})
}

View File

@@ -1,9 +1,15 @@
import electron from '../../electron.cjs' module.exports = {
import config from '../config.js' init
import log from './log.js' }
const ANNOUNCEMENT_URL = const electron = require('electron')
`${config.ANNOUNCEMENT_URL}?version=${config.APP_VERSION}&platform=${process.platform}`
const config = require('../config')
const log = require('./log')
const ANNOUNCEMENT_URL = config.ANNOUNCEMENT_URL +
'?version=' + config.APP_VERSION +
'&platform=' + process.platform
/** /**
* In certain situations, the WebTorrent team may need to show an announcement to * In certain situations, the WebTorrent team may need to show an announcement to
@@ -19,8 +25,8 @@ const ANNOUNCEMENT_URL =
* "detail": "Please update to v0.xx as soon as possible..." * "detail": "Please update to v0.xx as soon as possible..."
* } * }
*/ */
async function init () { function init () {
const { default: get } = await import('simple-get') const get = require('simple-get')
get.concat(ANNOUNCEMENT_URL, onResponse) get.concat(ANNOUNCEMENT_URL, onResponse)
} }
@@ -45,7 +51,7 @@ function onResponse (err, res, data) {
title: data.title, title: data.title,
message: data.message, message: data.message,
detail: data.detail detail: data.detail
}) }, noop)
} }
export default { init } function noop () {}

View File

@@ -1,18 +1,25 @@
import electron from '../../electron.cjs' module.exports = {
import log from './log.js' openSeedFile,
import * as windows from './windows/index.js' openSeedDirectory,
openTorrentFile,
openTorrentAddress,
openFiles
}
const { dialog } = electron const electron = require('electron')
const log = require('./log')
const windows = require('./windows')
/** /**
* Show open dialog to create a single-file torrent. * Show open dialog to create a single-file torrent.
*/ */
export function openSeedFile () { function openSeedFile () {
if (!windows.main.win) return if (!windows.main.win) return
log('openSeedFile') log('openSeedFile')
const opts = { const opts = {
title: 'Select a file for the torrent.', title: 'Select a file for the torrent.',
properties: ['openFile'] properties: [ 'openFile' ]
} }
showOpenSeed(opts) showOpenSeed(opts)
} }
@@ -22,18 +29,18 @@ export function openSeedFile () {
* Windows and Linux, open dialogs are for files *or* directories only, not both, * Windows and Linux, open dialogs are for files *or* directories only, not both,
* so this function shows a directory dialog on those platforms. * so this function shows a directory dialog on those platforms.
*/ */
export function openSeedDirectory () { function openSeedDirectory () {
if (!windows.main.win) return if (!windows.main.win) return
log('openSeedDirectory') log('openSeedDirectory')
const opts = process.platform === 'darwin' const opts = process.platform === 'darwin'
? { ? {
title: 'Select a file or folder for the torrent.', title: 'Select a file or folder for the torrent.',
properties: ['openFile', 'openDirectory'] properties: [ 'openFile', 'openDirectory' ]
} }
: { : {
title: 'Select a folder for the torrent.', title: 'Select a folder for the torrent.',
properties: ['openDirectory'] properties: [ 'openDirectory' ]
} }
showOpenSeed(opts) showOpenSeed(opts)
} }
@@ -41,49 +48,51 @@ export function openSeedDirectory () {
* Show flexible open dialog that supports selecting .torrent files to add, or * Show flexible open dialog that supports selecting .torrent files to add, or
* a file or folder to create a single-file or single-directory torrent. * a file or folder to create a single-file or single-directory torrent.
*/ */
export function openFiles () { function openFiles () {
if (!windows.main.win) return if (!windows.main.win) return
log('openFiles') log('openFiles')
const opts = process.platform === 'darwin' const opts = process.platform === 'darwin'
? { ? {
title: 'Select a file or folder to add.', title: 'Select a file or folder to add.',
properties: ['openFile', 'openDirectory'] properties: [ 'openFile', 'openDirectory' ]
} }
: { : {
title: 'Select a file to add.', title: 'Select a file to add.',
properties: ['openFile'] properties: [ 'openFile' ]
} }
setTitle(opts.title) setTitle(opts.title)
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts) electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle() resetTitle()
if (!Array.isArray(selectedPaths)) return if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('onOpen', selectedPaths) windows.main.dispatch('onOpen', selectedPaths)
})
} }
/* /*
* Show open dialog to open a .torrent file. * Show open dialog to open a .torrent file.
*/ */
export function openTorrentFile () { function openTorrentFile () {
if (!windows.main.win) return if (!windows.main.win) return
log('openTorrentFile') log('openTorrentFile')
const opts = { const opts = {
title: 'Select a .torrent file.', title: 'Select a .torrent file.',
filters: [{ name: 'Torrent Files', extensions: ['torrent'] }], filters: [{ name: 'Torrent Files', extensions: ['torrent'] }],
properties: ['openFile', 'multiSelections'] properties: [ 'openFile', 'multiSelections' ]
} }
setTitle(opts.title) setTitle(opts.title)
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts) electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle() resetTitle()
if (!Array.isArray(selectedPaths)) return if (!Array.isArray(selectedPaths)) return
selectedPaths.forEach(selectedPath => { selectedPaths.forEach(function (selectedPath) {
windows.main.dispatch('addTorrent', selectedPath) windows.main.dispatch('addTorrent', selectedPath)
})
}) })
} }
/* /*
* Show modal dialog to open a torrent URL (magnet uri, http torrent link, etc.) * Show modal dialog to open a torrent URL (magnet uri, http torrent link, etc.)
*/ */
export function openTorrentAddress () { function openTorrentAddress () {
log('openTorrentAddress') log('openTorrentAddress')
windows.main.dispatch('openTorrentAddress') windows.main.dispatch('openTorrentAddress')
} }
@@ -107,8 +116,9 @@ function resetTitle () {
*/ */
function showOpenSeed (opts) { function showOpenSeed (opts) {
setTitle(opts.title) setTitle(opts.title)
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts) electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle() resetTitle()
if (!Array.isArray(selectedPaths)) return if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths) windows.main.dispatch('showCreateTorrent', selectedPaths)
})
} }

View File

@@ -1,8 +1,13 @@
import electron from '../../electron.cjs' module.exports = {
import * as dialog from './dialog.js' downloadFinished,
import log from './log.js' init,
setBadge
}
const { app, Menu } = electron const {app, Menu} = require('electron')
const dialog = require('./dialog')
const log = require('./log')
/** /**
* Add a right-click menu to the dock icon. (Mac) * Add a right-click menu to the dock icon. (Mac)
@@ -27,9 +32,9 @@ function downloadFinished (path) {
*/ */
function setBadge (count) { function setBadge (count) {
if (process.platform === 'darwin' || if (process.platform === 'darwin' ||
(process.platform === 'linux' && app.isUnityRunning())) { process.platform === 'linux' && app.isUnityRunning()) {
log(`setBadge: ${count}`) log(`setBadge: ${count}`)
app.badgeCount = Number(count) app.setBadgeCount(Number(count))
} }
} }
@@ -52,5 +57,3 @@ function getMenuTemplate () {
} }
] ]
} }
export default { downloadFinished, init, setBadge }

View File

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

View File

@@ -1,48 +0,0 @@
import * as chokidar from 'chokidar'
import log from './log.js'
export class FolderWatcher {
constructor ({ window, state }) {
this.window = window
this.state = state
this.torrentsFolderPath = null
this.watching = false
}
isEnabled () {
return this.state.saved.prefs.autoAddTorrents
}
start () {
// Stop watching previous folder before
// start watching a new one.
if (this.watching) this.stop()
const torrentsFolderPath = this.state.saved.prefs.torrentsFolderPath
this.torrentsFolderPath = torrentsFolderPath
if (!torrentsFolderPath) return
const glob = `${torrentsFolderPath}/**/*.torrent`
log('Folder Watcher: watching: ', glob)
const options = {
ignoreInitial: true,
awaitWriteFinish: true
}
this.watcher = chokidar.watch(glob, options)
this.watcher
.on('add', (path) => {
log('Folder Watcher: added torrent: ', path)
this.window.dispatch('addTorrent', path)
})
this.watching = true
}
stop () {
log('Folder Watcher: stop.')
if (!this.watching) return
this.watcher.close()
this.watching = false
}
}

View File

@@ -1,26 +1,39 @@
import config from '../config.js' module.exports = {
import path from 'path' install,
uninstall
}
export function install () { const config = require('../config')
switch (process.platform) { const path = require('path')
case 'darwin': installDarwin()
break function install () {
case 'win32': installWin32() if (process.platform === 'darwin') {
break installDarwin()
}
if (process.platform === 'win32') {
installWin32()
}
if (process.platform === 'linux') {
installLinux()
} }
} }
export function uninstall () { function uninstall () {
switch (process.platform) { if (process.platform === 'darwin') {
case 'darwin': uninstallDarwin() uninstallDarwin()
break }
case 'win32': uninstallWin32() if (process.platform === 'win32') {
break uninstallWin32()
}
if (process.platform === 'linux') {
uninstallLinux()
} }
} }
async function installDarwin () { function installDarwin () {
const { app } = await import('electron') const electron = require('electron')
const app = electron.app
// On Mac, only protocols that are listed in `Info.plist` can be set as the // On Mac, only protocols that are listed in `Info.plist` can be set as the
// default handler at runtime. // default handler at runtime.
app.setAsDefaultProtocolClient('magnet') app.setAsDefaultProtocolClient('magnet')
@@ -31,20 +44,39 @@ async function installDarwin () {
function uninstallDarwin () {} function uninstallDarwin () {}
const EXEC_COMMAND = [process.execPath, '--'] const EXEC_COMMAND = [ process.execPath ]
if (!config.IS_PRODUCTION) { if (!config.IS_PRODUCTION) {
EXEC_COMMAND.push(config.ROOT_PATH) EXEC_COMMAND.push(config.ROOT_PATH)
} }
async function installWin32 () { function installWin32 () {
const Registry = await import('wingreg') const Registry = require('winreg')
const log = await import('./log')
const iconPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico') const log = require('./log')
registerProtocolHandlerWin32('magnet', 'URL:BitTorrent Magnet URL', iconPath, EXEC_COMMAND)
registerProtocolHandlerWin32('stream-magnet', 'URL:BitTorrent Stream-Magnet URL', iconPath, EXEC_COMMAND) const iconPath = path.join(
registerFileHandlerWin32('.torrent', 'io.webtorrent.torrent', 'BitTorrent Document', iconPath, EXEC_COMMAND) process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico'
)
registerProtocolHandlerWin32(
'magnet',
'URL:BitTorrent Magnet URL',
iconPath,
EXEC_COMMAND
)
registerProtocolHandlerWin32(
'stream-magnet',
'URL:BitTorrent Stream-Magnet URL',
iconPath,
EXEC_COMMAND
)
registerFileHandlerWin32(
'.torrent',
'io.webtorrent.torrent',
'BitTorrent Document',
iconPath,
EXEC_COMMAND
)
/** /**
* To add a protocol handler, the following keys must be added to the Windows registry: * To add a protocol handler, the following keys must be added to the Windows registry:
@@ -76,37 +108,37 @@ async function installWin32 () {
setProtocol() setProtocol()
function setProtocol (err) { function setProtocol (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
protocolKey.set('', Registry.REG_SZ, name, setURLProtocol) protocolKey.set('', Registry.REG_SZ, name, setURLProtocol)
} }
function setURLProtocol (err) { function setURLProtocol (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
protocolKey.set('URL Protocol', Registry.REG_SZ, '', setIcon) protocolKey.set('URL Protocol', Registry.REG_SZ, '', setIcon)
} }
function setIcon (err) { function setIcon (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
const iconKey = new Registry({ const iconKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${protocol}\\DefaultIcon` key: '\\Software\\Classes\\' + protocol + '\\DefaultIcon'
}) })
iconKey.set('', Registry.REG_SZ, icon, setCommand) iconKey.set('', Registry.REG_SZ, icon, setCommand)
} }
function setCommand (err) { function setCommand (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
const commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${protocol}\\shell\\open\\command` key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
}) })
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done) commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
} }
function done (err) { function done (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
} }
} }
@@ -131,49 +163,50 @@ async function installWin32 () {
function setExt () { function setExt () {
const extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: `\\Software\\Classes\\${ext}` key: '\\Software\\Classes\\' + ext
}) })
extKey.set('', Registry.REG_SZ, id, setId) extKey.set('', Registry.REG_SZ, id, setId)
} }
function setId (err) { function setId (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
const idKey = new Registry({ const idKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${id}` key: '\\Software\\Classes\\' + id
}) })
idKey.set('', Registry.REG_SZ, name, setIcon) idKey.set('', Registry.REG_SZ, name, setIcon)
} }
function setIcon (err) { function setIcon (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
const iconKey = new Registry({ const iconKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${id}\\DefaultIcon` key: '\\Software\\Classes\\' + id + '\\DefaultIcon'
}) })
iconKey.set('', Registry.REG_SZ, icon, setCommand) iconKey.set('', Registry.REG_SZ, icon, setCommand)
} }
function setCommand (err) { function setCommand (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
const commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${id}\\shell\\open\\command` key: '\\Software\\Classes\\' + id + '\\shell\\open\\command'
}) })
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done) commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
} }
function done (err) { function done (err) {
if (err) return log.error(err.message) if (err) log.error(err.message)
} }
} }
} }
async function uninstallWin32 () { function uninstallWin32 () {
const Registry = await import('winreg') const Registry = require('winreg')
unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND) unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND)
unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND) unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND)
unregisterFileHandlerWin32('.torrent', 'io.webtorrent.torrent', EXEC_COMMAND) unregisterFileHandlerWin32('.torrent', 'io.webtorrent.torrent', EXEC_COMMAND)
@@ -184,9 +217,9 @@ async function uninstallWin32 () {
function getCommand () { function getCommand () {
const commandKey = new Registry({ const commandKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: `\\Software\\Classes\\${protocol}\\shell\\open\\command` key: '\\Software\\Classes\\' + protocol + '\\shell\\open\\command'
}) })
commandKey.get('', (err, item) => { commandKey.get('', function (err, item) {
if (!err && item.value.indexOf(commandToArgs(command)) >= 0) { if (!err && item.value.indexOf(commandToArgs(command)) >= 0) {
destroyProtocol() destroyProtocol()
} }
@@ -196,9 +229,9 @@ async function uninstallWin32 () {
function destroyProtocol () { function destroyProtocol () {
const protocolKey = new Registry({ const protocolKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${protocol}` key: '\\Software\\Classes\\' + protocol
}) })
protocolKey.destroy(() => {}) protocolKey.destroy(function () {})
} }
} }
@@ -208,7 +241,7 @@ async function uninstallWin32 () {
function eraseId () { function eraseId () {
const idKey = new Registry({ const idKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: `\\Software\\Classes\\${id}` key: '\\Software\\Classes\\' + id
}) })
idKey.destroy(getExt) idKey.destroy(getExt)
} }
@@ -216,9 +249,9 @@ async function uninstallWin32 () {
function getExt () { function getExt () {
const extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${ext}` key: '\\Software\\Classes\\' + ext
}) })
extKey.get('', (err, item) => { extKey.get('', function (err, item) {
if (!err && item.value === id) { if (!err && item.value === id) {
destroyExt() destroyExt()
} }
@@ -228,9 +261,9 @@ async function uninstallWin32 () {
function destroyExt () { function destroyExt () {
const extKey = new Registry({ const extKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: `\\Software\\Classes\\${ext}` key: '\\Software\\Classes\\' + ext
}) })
extKey.destroy(() => {}) extKey.destroy(function () {})
} }
} }
} }
@@ -238,3 +271,99 @@ async function uninstallWin32 () {
function commandToArgs (command) { function commandToArgs (command) {
return command.map((arg) => `"${arg}"`).join(' ') return command.map((arg) => `"${arg}"`).join(' ')
} }
function installLinux () {
const fs = require('fs')
const os = require('os')
const path = require('path')
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 () {
const templatePath = path.join(
config.STATIC_PATH, 'linux', 'webtorrent-desktop.desktop'
)
fs.readFile(templatePath, 'utf8', writeDesktopFile)
}
function writeDesktopFile (err, desktopFile) {
if (err) return log.error(err.message)
const appPath = config.IS_PRODUCTION
? path.dirname(process.execPath)
: config.ROOT_PATH
desktopFile = desktopFile.replace(/\$APP_NAME/g, config.APP_NAME)
desktopFile = desktopFile.replace(/\$APP_PATH/g, appPath)
desktopFile = desktopFile.replace(/\$EXEC_PATH/g, EXEC_COMMAND.join(' '))
desktopFile = desktopFile.replace(/\$TRY_EXEC_PATH/g, process.execPath)
const desktopFilePath = path.join(
os.homedir(),
'.local',
'share',
'applications',
'webtorrent-desktop.desktop'
)
fs.mkdirp(path.dirname(desktopFilePath))
fs.writeFile(desktopFilePath, desktopFile, function (err) {
if (err) return log.error(err.message)
})
}
function installIconFile () {
const iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png')
fs.readFile(iconStaticPath, writeIconFile)
}
function writeIconFile (err, iconFile) {
if (err) return log.error(err.message)
const mkdirp = require('mkdirp')
const iconFilePath = path.join(
os.homedir(),
'.local',
'share',
'icons',
'webtorrent-desktop.png'
)
mkdirp(path.dirname(iconFilePath), (err) => {
if (err) return log.error(err.message)
fs.writeFile(iconFilePath, iconFile, (err) => {
if (err) log.error(err.message)
})
})
}
}
function uninstallLinux () {
const os = require('os')
const path = require('path')
const rimraf = require('rimraf')
const desktopFilePath = path.join(
os.homedir(),
'.local',
'share',
'applications',
'webtorrent-desktop.desktop'
)
rimraf(desktopFilePath)
const iconFilePath = path.join(
os.homedir(),
'.local',
'share',
'icons',
'webtorrent-desktop.png'
)
rimraf(iconFilePath)
}

View File

@@ -1,32 +1,21 @@
/* eslint-disable import/first */
console.time('init') console.time('init')
import * as RemoteMain from '@electron/remote/main/index.js' const electron = require('electron')
RemoteMain.initialize() const app = electron.app
import electron from '../../electron.cjs'
const { app, ipcMain } = electron
// Start crash reporter early, so it takes effect for child processes const parallel = require('run-parallel')
import crashReporter from '../crash-reporter.js'
crashReporter.init()
import fs from 'fs' const config = require('../config')
import parallel from 'run-parallel' const crashReporter = require('../crash-reporter')
import config from '../config.js' const ipc = require('./ipc')
import ipc from './ipc.js' const log = require('./log')
import log from './log.js' const menu = require('./menu')
import menu from './menu.js' const State = require('../renderer/lib/state')
import State from '../renderer/lib/state.js' const windows = require('./windows')
import * as windows from './windows/index.js'
const WEBTORRENT_VERSION = JSON.parse(fs.readFileSync('node_modules/webtorrent/package.json').toString()).version
let shouldQuit = false let shouldQuit = false
let argv = sliceArgv(process.argv) let argv = sliceArgv(process.argv)
// allow electron/chromium to play startup sounds (without user interaction)
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
// Start the app without showing the main window when auto launching on login // Start the app without showing the main window when auto launching on login
// (On Windows and Linux, we get a flag. On MacOS, we get special API.) // (On Windows and Linux, we get a flag. On MacOS, we get special API.)
const hidden = argv.includes('--hidden') || const hidden = argv.includes('--hidden') ||
@@ -49,38 +38,33 @@ if (!shouldQuit && !config.IS_PORTABLE) {
// signal this instance and quit. Note: This feature creates a lock file in // signal this instance and quit. Note: This feature creates a lock file in
// %APPDATA%\Roaming\WebTorrent so we do not do it for the Portable App since // %APPDATA%\Roaming\WebTorrent so we do not do it for the Portable App since
// we want to be "silent" as well as "portable". // we want to be "silent" as well as "portable".
if (!app.requestSingleInstanceLock()) { shouldQuit = app.makeSingleInstance(onAppOpen)
shouldQuit = true if (shouldQuit) {
app.quit()
} }
} }
if (shouldQuit) { if (!shouldQuit) {
app.quit() init()
} else {
init().catch(console.error)
} }
async function init () { function init () {
console.log('index init')
app.whenReady().then(() => {
console.log('readyyyy')
})
app.on('second-instance', (event, commandLine, workingDirectory) => onAppOpen(commandLine))
if (config.IS_PORTABLE) { if (config.IS_PORTABLE) {
console.log('is portable') const path = require('path')
const path = await import('path')
// Put all user data into the "Portable Settings" folder // Put all user data into the "Portable Settings" folder
app.setPath('userData', config.CONFIG_PATH) app.setPath('userData', config.CONFIG_PATH)
// Put Electron crash files, etc. into the "Portable Settings\Temp" folder // Put Electron crash files, etc. into the "Portable Settings\Temp" folder
app.setPath('temp', path.join(config.CONFIG_PATH, 'Temp')) app.setPath('temp', path.join(config.CONFIG_PATH, 'Temp'))
} }
const ipcMain = electron.ipcMain
let 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.ipcReady = false // main window has finished loading and IPC is ready
app.isQuitting = false app.isQuitting = false
parallel({ parallel({
appReady: (cb) => app.whenReady().then(cb), appReady: (cb) => app.on('ready', () => cb(null)),
state: (cb) => State.load(cb) state: (cb) => State.load(cb)
}, onReady) }, onReady)
@@ -88,43 +72,38 @@ async function init () {
if (err) throw err if (err) throw err
isReady = true isReady = true
const state = results.state
menu.init() windows.main.init(results.state, {hidden: hidden})
windows.main.init(state, { hidden })
windows.webtorrent.init() windows.webtorrent.init()
menu.init()
// To keep app startup fast, some code is delayed. // To keep app startup fast, some code is delayed.
setTimeout(() => { setTimeout(delayedInit, config.DELAYED_INIT)
delayedInit(state)
}, config.DELAYED_INIT)
// Report uncaught exceptions // Report uncaught exceptions
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
console.error(err) console.error(err)
const error = { message: err.message, stack: err.stack } const error = {message: err.message, stack: err.stack}
windows.main.dispatch('uncaughtError', 'main', error) windows.main.dispatch('uncaughtError', 'main', error)
}) })
} }
// Enable app logging into default directory, i.e. /Library/Logs/WebTorrent
// on Mac, %APPDATA% on Windows, $XDG_CONFIG_HOME or ~/.config on Linux.
app.setAppLogsPath()
app.userAgentFallback = `WebTorrent/${WEBTORRENT_VERSION} (https://webtorrent.io)`
app.on('open-file', onOpen) app.on('open-file', onOpen)
app.on('open-url', onOpen) app.on('open-url', onOpen)
ipc.init() ipc.init()
app.once('ipcReady', () => { app.once('will-finish-launching', function () {
crashReporter.init()
})
app.once('ipcReady', function () {
log('Command line args:', argv) log('Command line args:', argv)
processArgv(argv) processArgv(argv)
console.timeEnd('init') console.timeEnd('init')
}) })
app.on('before-quit', e => { app.on('before-quit', function (e) {
if (app.isQuitting) return if (app.isQuitting) return
app.isQuitting = true app.isQuitting = true
@@ -137,38 +116,30 @@ async function init () {
}, 4000) // quit after 4 secs, at most }, 4000) // quit after 4 secs, at most
}) })
app.on('activate', () => { app.on('activate', function () {
console.log('activate')
if (isReady) windows.main.show() if (isReady) windows.main.show()
}) })
} }
async function delayedInit (state) { function delayedInit () {
if (app.isQuitting) return if (app.isQuitting) return
const { default: announcement } = await import('./announcement.js') const announcement = require('./announcement')
const { default: dock } = await import('./dock.js') const dock = require('./dock')
const { default: updater } = await import('./updater.js') const updater = require('./updater')
const { FolderWatcher } = await import('./folder-watcher.js')
const folderWatcher = new FolderWatcher({ window: windows.main, state })
announcement.init() announcement.init()
dock.init() dock.init()
updater.init() updater.init()
ipc.setModule('folderWatcher', folderWatcher)
if (folderWatcher.isEnabled()) {
folderWatcher.start()
}
if (process.platform === 'win32') { if (process.platform === 'win32') {
const userTasks = await import('./user-tasks.js') const userTasks = require('./user-tasks')
userTasks.init() userTasks.init()
} }
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
const { init: trayInit } = await import('./tray.js') const tray = require('./tray')
trayInit() tray.init()
} }
} }
@@ -181,7 +152,7 @@ function onOpen (e, torrentId) {
// Electron issue: https://github.com/atom/electron/issues/4338 // Electron issue: https://github.com/atom/electron/issues/4338
setTimeout(() => windows.main.show(), 100) setTimeout(() => windows.main.show(), 100)
processArgv([torrentId]) processArgv([ torrentId ])
} else { } else {
argv.push(torrentId) argv.push(torrentId)
} }
@@ -205,21 +176,17 @@ function onAppOpen (newArgv) {
// Development: 2 args, eg: electron . // Development: 2 args, eg: electron .
// Test: 4 args, eg: electron -r .../mocks.js . // Test: 4 args, eg: electron -r .../mocks.js .
function sliceArgv (argv) { function sliceArgv (argv) {
return argv.slice( return argv.slice(config.IS_PRODUCTION ? 1
config.IS_PRODUCTION : config.IS_TEST ? 4
? 1 : 2)
: config.IS_TEST
? 4
: 2
)
} }
async function processArgv (argv) { function processArgv (argv) {
const torrentIds = [] let torrentIds = []
await Promise.all(argv.forEach(async arg => { argv.forEach(function (arg) {
if (arg === '-n' || arg === '-o' || arg === '-u') { if (arg === '-n' || arg === '-o' || arg === '-u') {
// Critical path: Only load the 'dialog' package if it is needed // Critical path: Only load the 'dialog' package if it is needed
const dialog = await import('./dialog') const dialog = require('./dialog')
if (arg === '-n') { if (arg === '-n') {
dialog.openSeedDirectory() dialog.openSeedDirectory()
} else if (arg === '-o') { } else if (arg === '-o') {
@@ -231,7 +198,7 @@ async function processArgv (argv) {
// Ignore hidden argument, already being handled // Ignore hidden argument, already being handled
} else if (arg.startsWith('-psn')) { } else if (arg.startsWith('-psn')) {
// Ignore Mac launchd "process serial number" argument // Ignore Mac launchd "process serial number" argument
// Issue: https://github.com/webtorrent/webtorrent-desktop/issues/214 // Issue: https://github.com/feross/webtorrent-desktop/issues/214
} else if (arg.startsWith('--')) { } else if (arg.startsWith('--')) {
// Ignore Spectron flags // Ignore Spectron flags
} else if (arg === 'data:,') { } else if (arg === 'data:,') {
@@ -242,7 +209,7 @@ async function processArgv (argv) {
// running. // running.
torrentIds.push(arg) torrentIds.push(arg)
} }
})) })
if (torrentIds.length > 0) { if (torrentIds.length > 0) {
windows.main.dispatch('onOpen', torrentIds) windows.main.dispatch('onOpen', torrentIds)
} }

View File

@@ -1,33 +1,31 @@
import electron from '../../electron.cjs' module.exports = {
init
}
import log from './log.js' const electron = require('electron')
import menu from './menu.js'
import * as windows from './windows/index.js'
const { app, ipcMain } = electron const app = electron.app
const log = require('./log')
const menu = require('./menu')
const windows = require('./windows')
// Messages from the main process, to be sent once the WebTorrent process starts // Messages from the main process, to be sent once the WebTorrent process starts
const messageQueueMainToWebTorrent = [] const messageQueueMainToWebTorrent = []
// Will hold modules injected from the app that will be used on fired
// IPC events.
const modules = {}
function setModule (name, module) {
modules[name] = module
}
function init () { function init () {
ipcMain.once('ipcReady', e => { const ipc = electron.ipcMain
ipc.once('ipcReady', function (e) {
app.ipcReady = true app.ipcReady = true
app.emit('ipcReady') app.emit('ipcReady')
}) })
ipcMain.once('ipcReadyWebTorrent', e => { ipc.once('ipcReadyWebTorrent', function (e) {
app.ipcReadyWebTorrent = true app.ipcReadyWebTorrent = true
log('sending %d queued messages from the main win to the webtorrent window', log('sending %d queued messages from the main win to the webtorrent window',
messageQueueMainToWebTorrent.length) messageQueueMainToWebTorrent.length)
messageQueueMainToWebTorrent.forEach(message => { messageQueueMainToWebTorrent.forEach(function (message) {
windows.webtorrent.send(message.name, ...message.args) windows.webtorrent.send(message.name, ...message.args)
log('webtorrent: sent queued %s', message.name) log('webtorrent: sent queued %s', message.name)
}) })
@@ -37,12 +35,12 @@ function init () {
* Dialog * Dialog
*/ */
ipcMain.on('openTorrentFile', async () => { ipc.on('openTorrentFile', () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openTorrentFile() dialog.openTorrentFile()
}) })
ipcMain.on('openFiles', async () => { ipc.on('openFiles', () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openFiles() dialog.openFiles()
}) })
@@ -50,23 +48,23 @@ function init () {
* Dock * Dock
*/ */
ipcMain.on('setBadge', async (e, ...args) => { ipc.on('setBadge', (e, ...args) => {
const dock = await import('./dock') const dock = require('./dock')
dock.setBadge(...args) dock.setBadge(...args)
}) })
ipcMain.on('downloadFinished', async (e, ...args) => { ipc.on('downloadFinished', (e, ...args) => {
const dock = await import('./dock') const dock = require('./dock')
dock.downloadFinished(...args) dock.downloadFinished(...args)
}) })
/** /**
* Player Events * Events
*/ */
ipcMain.on('onPlayerOpen', async () => { ipc.on('onPlayerOpen', function () {
const powerSaveBlocker = await import('./power-save-blocker') const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = await import('./shortcuts') const shortcuts = require('./shortcuts')
const thumbar = await import('./thumbar') const thumbar = require('./thumbar')
menu.togglePlaybackControls(true) menu.togglePlaybackControls(true)
powerSaveBlocker.enable() powerSaveBlocker.enable()
@@ -74,17 +72,17 @@ function init () {
thumbar.enable() thumbar.enable()
}) })
ipcMain.on('onPlayerUpdate', async (e, ...args) => { ipc.on('onPlayerUpdate', function (e, ...args) {
const thumbar = await import('./thumbar') const thumbar = require('./thumbar')
menu.onPlayerUpdate(...args) menu.onPlayerUpdate(...args)
thumbar.onPlayerUpdate(...args) thumbar.onPlayerUpdate(...args)
}) })
ipcMain.on('onPlayerClose', async () => { ipc.on('onPlayerClose', function () {
const powerSaveBlocker = await import('./power-save-blocker') const powerSaveBlocker = require('./power-save-blocker')
const shortcuts = await import('./shortcuts') const shortcuts = require('./shortcuts')
const thumbar = await import('./thumbar') const thumbar = require('./thumbar')
menu.togglePlaybackControls(false) menu.togglePlaybackControls(false)
powerSaveBlocker.disable() powerSaveBlocker.disable()
@@ -92,58 +90,36 @@ function init () {
thumbar.disable() thumbar.disable()
}) })
ipcMain.on('onPlayerPlay', async () => { ipc.on('onPlayerPlay', function () {
const powerSaveBlocker = await import('./power-save-blocker') const powerSaveBlocker = require('./power-save-blocker')
const thumbar = await import('./thumbar') const thumbar = require('./thumbar')
powerSaveBlocker.enable() powerSaveBlocker.enable()
thumbar.onPlayerPlay() thumbar.onPlayerPlay()
}) })
ipcMain.on('onPlayerPause', async () => { ipc.on('onPlayerPause', function () {
const powerSaveBlocker = await import('./power-save-blocker') const powerSaveBlocker = require('./power-save-blocker')
const thumbar = await import('./thumbar') const thumbar = require('./thumbar')
powerSaveBlocker.disable() powerSaveBlocker.disable()
thumbar.onPlayerPause() thumbar.onPlayerPause()
}) })
/**
* Folder Watcher Events
*/
ipcMain.on('startFolderWatcher', () => {
if (!modules.folderWatcher) {
log('IPC ERR: folderWatcher module is not defined.')
return
}
modules.folderWatcher.start()
})
ipcMain.on('stopFolderWatcher', () => {
if (!modules.folderWatcher) {
log('IPC ERR: folderWatcher module is not defined.')
return
}
modules.folderWatcher.stop()
})
/** /**
* Shell * Shell
*/ */
ipcMain.on('openPath', async (e, ...args) => { ipc.on('openItem', (e, ...args) => {
const shell = await import('./shell') const shell = require('./shell')
shell.openPath(...args) shell.openItem(...args)
}) })
ipcMain.on('showItemInFolder', async (e, ...args) => { ipc.on('showItemInFolder', (e, ...args) => {
const shell = await import('./shell') const shell = require('./shell')
shell.showItemInFolder(...args) shell.showItemInFolder(...args)
}) })
ipcMain.on('moveItemToTrash', async (e, ...args) => { ipc.on('moveItemToTrash', (e, ...args) => {
const shell = await import('./shell') const shell = require('./shell')
shell.moveItemToTrash(...args) shell.moveItemToTrash(...args)
}) })
@@ -151,8 +127,8 @@ function init () {
* File handlers * File handlers
*/ */
ipcMain.on('setDefaultFileHandler', async (e, flag) => { ipc.on('setDefaultFileHandler', (e, flag) => {
const handlers = await import('./handlers') const handlers = require('./handlers')
if (flag) handlers.install() if (flag) handlers.install()
else handlers.uninstall() else handlers.uninstall()
@@ -162,8 +138,8 @@ function init () {
* Auto start on login * Auto start on login
*/ */
ipcMain.on('setStartup', async (e, flag) => { ipc.on('setStartup', (e, flag) => {
const startup = await import('./startup') const startup = require('./startup')
if (flag) startup.install() if (flag) startup.install()
else startup.uninstall() else startup.uninstall()
@@ -175,39 +151,37 @@ function init () {
const main = windows.main const main = windows.main
ipcMain.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args)) ipc.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
ipcMain.on('setBounds', (e, ...args) => main.setBounds(...args)) ipc.on('setBounds', (e, ...args) => main.setBounds(...args))
ipcMain.on('setProgress', (e, ...args) => main.setProgress(...args)) ipc.on('setProgress', (e, ...args) => main.setProgress(...args))
ipcMain.on('setTitle', (e, ...args) => main.setTitle(...args)) ipc.on('setTitle', (e, ...args) => main.setTitle(...args))
ipcMain.on('show', () => main.show()) ipc.on('show', () => main.show())
ipcMain.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args)) ipc.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args))
ipcMain.on('setAllowNav', (e, ...args) => menu.setAllowNav(...args)) ipc.on('setAllowNav', (e, ...args) => menu.setAllowNav(...args))
/** /**
* External Media Player * External Media Player
*/ */
ipcMain.on('checkForExternalPlayer', async (e, path) => { ipc.on('checkForExternalPlayer', function (e, path) {
const externalPlayer = await import('./external-player') const externalPlayer = require('./external-player')
externalPlayer.checkInstall(path, err => { externalPlayer.checkInstall(path, function (isInstalled) {
windows.main.send('checkForExternalPlayer', !err) windows.main.send('checkForExternalPlayer', isInstalled)
}) })
}) })
ipcMain.on('openExternalPlayer', async (e, ...args) => { ipc.on('openExternalPlayer', (e, ...args) => {
const externalPlayer = await import('./external-player') const externalPlayer = require('./external-player')
const shortcuts = await import('./shortcuts') const thumbar = require('./thumbar')
const thumbar = await import('./thumbar')
menu.togglePlaybackControls(false) menu.togglePlaybackControls(false)
shortcuts.disable()
thumbar.disable() thumbar.disable()
externalPlayer.spawn(...args) externalPlayer.spawn(...args)
}) })
ipcMain.on('quitExternalPlayer', async () => { ipc.on('quitExternalPlayer', () => {
const externalPlayer = await import('./external-player') const externalPlayer = require('./external-player')
externalPlayer.kill() externalPlayer.kill()
}) })
@@ -215,12 +189,11 @@ function init () {
* Message passing * Message passing
*/ */
const oldEmit = ipcMain.emit const oldEmit = ipc.emit
ipcMain.emit = (name, e, ...args) => { ipc.emit = function (name, e, ...args) {
// Relay messages between the main window and the WebTorrent hidden window // Relay messages between the main window and the WebTorrent hidden window
if (name.startsWith('wt-') && !app.isQuitting) { if (name.startsWith('wt-') && !app.isQuitting) {
console.dir(e.sender.getTitle()) if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') {
if (e.sender.getTitle() === 'WebTorrent Hidden Window') {
// Send message to main window // Send message to main window
windows.main.send(name, ...args) windows.main.send(name, ...args)
log('webtorrent: got %s', name) log('webtorrent: got %s', name)
@@ -231,8 +204,8 @@ function init () {
} else { } else {
// Queue message for webtorrent window, it hasn't finished loading yet // Queue message for webtorrent window, it hasn't finished loading yet
messageQueueMainToWebTorrent.push({ messageQueueMainToWebTorrent.push({
name, name: name,
args args: args
}) })
log('webtorrent: queueing %s', name) log('webtorrent: queueing %s', name)
} }
@@ -240,13 +213,6 @@ function init () {
} }
// Emit all other events normally // Emit all other events normally
oldEmit.call(ipcMain, name, e, ...args) oldEmit.call(ipc, name, e, ...args)
} }
} }
export { init }
export { setModule }
export default {
init,
setModule
}

View File

@@ -1,7 +1,5 @@
import electron from '../../electron.cjs' module.exports = log
import * as windows from './windows/index.js' module.exports.error = error
const { app } = electron
/** /**
* In the main electron process, we do not use console.log() statements because they do * In the main electron process, we do not use console.log() statements because they do
@@ -9,7 +7,13 @@ const { app } = electron
* version of the app. Instead use this module, which sends the logs to the main window * version of the app. Instead use this module, which sends the logs to the main window
* where they can be viewed in Developer Tools. * where they can be viewed in Developer Tools.
*/ */
export default function log (...args) {
const electron = require('electron')
const windows = require('./windows')
const app = electron.app
function log (...args) {
if (app.ipcReady) { if (app.ipcReady) {
windows.main.send('log', ...args) windows.main.send('log', ...args)
} else { } else {
@@ -17,7 +21,7 @@ export default function log (...args) {
} }
} }
export function error (...args) { function error (...args) {
if (app.ipcReady) { if (app.ipcReady) {
windows.main.send('error', ...args) windows.main.send('error', ...args)
} else { } else {

View File

@@ -1,12 +1,23 @@
import electron from '../../electron.cjs' module.exports = {
import config from '../config.js' init,
import * as windows from './windows/index.js' togglePlaybackControls,
import shell from './shell.js' setWindowFocus,
setAllowNav,
onPlayerUpdate,
onToggleAlwaysOnTop,
onToggleFullScreen
}
const electron = require('electron')
const app = electron.app
const config = require('../config')
const windows = require('./windows')
let menu = null let menu = null
function init () { function init () {
console.log('menu init')
menu = electron.Menu.buildFromTemplate(getMenuTemplate()) menu = electron.Menu.buildFromTemplate(getMenuTemplate())
electron.Menu.setApplicationMenu(menu) electron.Menu.setApplicationMenu(menu)
} }
@@ -59,11 +70,12 @@ function onToggleFullScreen (flag) {
} }
function getMenuItem (label) { function getMenuItem (label) {
for (const menuItem of menu.items) { for (let i = 0; i < menu.items.length; i++) {
const submenuItem = menuItem.submenu.items.find(item => item.label === label) const menuItem = menu.items[i].submenu.items.find(function (item) {
if (submenuItem) return submenuItem return item.label === label
})
if (menuItem) return menuItem
} }
return {}
} }
function getMenuTemplate () { function getMenuTemplate () {
@@ -76,24 +88,24 @@ function getMenuTemplate () {
? 'Create New Torrent...' ? 'Create New Torrent...'
: 'Create New Torrent from Folder...', : 'Create New Torrent from Folder...',
accelerator: 'CmdOrCtrl+N', accelerator: 'CmdOrCtrl+N',
click: async () => { click: () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openSeedDirectory() dialog.openSeedDirectory()
} }
}, },
{ {
label: 'Open Torrent File...', label: 'Open Torrent File...',
accelerator: 'CmdOrCtrl+O', accelerator: 'CmdOrCtrl+O',
click: async () => { click: () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openTorrentFile() dialog.openTorrentFile()
} }
}, },
{ {
label: 'Open Torrent Address...', label: 'Open Torrent Address...',
accelerator: 'CmdOrCtrl+U', accelerator: 'CmdOrCtrl+U',
click: async () => { click: () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openTorrentAddress() dialog.openTorrentAddress()
} }
}, },
@@ -276,14 +288,6 @@ function getMenuTemplate () {
{ {
label: 'Resume All', label: 'Resume All',
click: () => windows.main.dispatch('resumeAllTorrents') click: () => windows.main.dispatch('resumeAllTorrents')
},
{
label: 'Remove All From List',
click: () => windows.main.dispatch('confirmDeleteAllTorrents', false)
},
{
label: 'Remove All Data Files',
click: () => windows.main.dispatch('confirmDeleteAllTorrents', true)
} }
] ]
}, },
@@ -294,18 +298,14 @@ function getMenuTemplate () {
{ {
label: 'Learn more about ' + config.APP_NAME, label: 'Learn more about ' + config.APP_NAME,
click: () => { click: () => {
const shell = require('./shell')
shell.openExternal(config.HOME_PAGE_URL) shell.openExternal(config.HOME_PAGE_URL)
} }
}, },
{
label: 'Release Notes',
click: () => {
shell.openExternal(config.GITHUB_URL_RELEASES)
}
},
{ {
label: 'Contribute on GitHub', label: 'Contribute on GitHub',
click: () => { click: () => {
const shell = require('./shell')
shell.openExternal(config.GITHUB_URL) shell.openExternal(config.GITHUB_URL)
} }
}, },
@@ -315,20 +315,16 @@ function getMenuTemplate () {
{ {
label: 'Report an Issue...', label: 'Report an Issue...',
click: () => { click: () => {
const shell = require('./shell')
shell.openExternal(config.GITHUB_URL_ISSUES) shell.openExternal(config.GITHUB_URL_ISSUES)
} }
},
{
label: 'Follow us on Twitter',
click: () => {
shell.openExternal(config.TWITTER_PAGE_URL)
}
} }
] ]
} }
] ]
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// WebTorrent menu (Mac) // Add WebTorrent app menu (Mac)
template.unshift({ template.unshift({
label: config.APP_NAME, label: config.APP_NAME,
submenu: [ submenu: [
@@ -347,7 +343,8 @@ function getMenuTemplate () {
type: 'separator' type: 'separator'
}, },
{ {
role: 'services' role: 'services',
submenu: []
}, },
{ {
type: 'separator' type: 'separator'
@@ -370,25 +367,7 @@ function getMenuTemplate () {
] ]
}) })
// Edit menu (Mac) // Add Window menu (Mac)
template[2].submenu.push(
{
type: 'separator'
},
{
label: 'Speech',
submenu: [
{
role: 'startspeaking'
},
{
role: 'stopspeaking'
}
]
}
)
// Window menu (Mac)
template.splice(6, 0, { template.splice(6, 0, {
role: 'window', role: 'window',
submenu: [ submenu: [
@@ -411,8 +390,8 @@ function getMenuTemplate () {
// File menu (Windows, Linux) // File menu (Windows, Linux)
template[0].submenu.unshift({ template[0].submenu.unshift({
label: 'Create New Torrent from File...', label: 'Create New Torrent from File...',
click: async () => { click: () => {
const dialog = await import('./dialog') const dialog = require('./dialog')
dialog.openSeedFile() dialog.openSeedFile()
} }
}) })
@@ -429,7 +408,7 @@ function getMenuTemplate () {
}) })
// Help menu (Windows, Linux) // Help menu (Windows, Linux)
template[5].submenu.push( template[4].submenu.push(
{ {
type: 'separator' type: 'separator'
}, },
@@ -445,26 +424,9 @@ function getMenuTemplate () {
// File menu (Linux) // File menu (Linux)
template[0].submenu.push({ template[0].submenu.push({
label: 'Quit', label: 'Quit',
click: () => electron.app.quit() click: () => app.quit()
}) })
} }
return template return template
} }
export { init }
export { togglePlaybackControls }
export { setWindowFocus }
export { setAllowNav }
export { onPlayerUpdate }
export { onToggleAlwaysOnTop }
export { onToggleFullScreen }
export default {
init,
togglePlaybackControls,
setWindowFocus,
setAllowNav,
onPlayerUpdate,
onToggleAlwaysOnTop,
onToggleFullScreen
}

View File

@@ -1,5 +1,10 @@
import { powerSaveBlocker } from 'electron' module.exports = {
import log from './log.js' enable,
disable
}
const electron = require('electron')
const log = require('./log')
let blockId = 0 let blockId = 0
@@ -7,23 +12,23 @@ let blockId = 0
* Block the system from entering low-power (sleep) mode or turning off the * Block the system from entering low-power (sleep) mode or turning off the
* display. * display.
*/ */
export function enable () { function enable () {
if (powerSaveBlocker.isStarted(blockId)) { if (electron.powerSaveBlocker.isStarted(blockId)) {
// If a power saver block already exists, do nothing. // If a power saver block already exists, do nothing.
return return
} }
blockId = powerSaveBlocker.start('prevent-display-sleep') blockId = electron.powerSaveBlocker.start('prevent-display-sleep')
log(`powerSaveBlocker.enable: ${blockId}`) log(`powerSaveBlocker.enable: ${blockId}`)
} }
/** /**
* Stop blocking the system from entering low-power mode. * Stop blocking the system from entering low-power mode.
*/ */
export function disable () { function disable () {
if (!powerSaveBlocker.isStarted(blockId)) { if (!electron.powerSaveBlocker.isStarted(blockId)) {
// If a power saver block does not exist, do nothing. // If a power saver block does not exist, do nothing.
return return
} }
powerSaveBlocker.stop(blockId) electron.powerSaveBlocker.stop(blockId)
log(`powerSaveBlocker.disable: ${blockId}`) log(`powerSaveBlocker.disable: ${blockId}`)
} }

View File

@@ -1,22 +1,27 @@
import electron from '../../electron.cjs' module.exports = {
import log from './log.js' openExternal,
openItem,
showItemInFolder,
moveItemToTrash
}
const { shell } = electron const electron = require('electron')
const log = require('./log')
/** /**
* Open the given external protocol URL in the desktops default manner. * Open the given external protocol URL in the desktops default manner.
*/ */
function openExternal (url) { function openExternal (url) {
log(`openExternal: ${url}`) log(`openExternal: ${url}`)
shell.openExternal(url) electron.shell.openExternal(url)
} }
/** /**
* Open the given file in the desktops default manner. * Open the given file in the desktops default manner.
*/ */
function openPath (path) { function openItem (path) {
log(`openPath: ${path}`) log(`openItem: ${path}`)
shell.openPath(path) electron.shell.openItem(path)
} }
/** /**
@@ -24,7 +29,7 @@ function openPath (path) {
*/ */
function showItemInFolder (path) { function showItemInFolder (path) {
log(`showItemInFolder: ${path}`) log(`showItemInFolder: ${path}`)
shell.showItemInFolder(path) electron.shell.showItemInFolder(path)
} }
/** /**
@@ -32,12 +37,5 @@ function showItemInFolder (path) {
*/ */
function moveItemToTrash (path) { function moveItemToTrash (path) {
log(`moveItemToTrash: ${path}`) log(`moveItemToTrash: ${path}`)
shell.trashItem(path) electron.shell.moveItemToTrash(path)
}
export default {
openExternal,
openPath,
showItemInFolder,
moveItemToTrash
} }

View File

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

View File

@@ -1,33 +1,151 @@
import { app } from 'electron' module.exports = {
import path from 'path' handleEvent
import { spawn } from 'child_process' }
import handlers from './handlers.js'
const cp = require('child_process')
const electron = require('electron')
const fs = require('fs')
const os = require('os')
const path = require('path')
const app = electron.app
const handlers = require('./handlers')
const EXE_NAME = path.basename(process.execPath) const EXE_NAME = path.basename(process.execPath)
const UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe') const UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe')
const run = (args, done) => { function handleEvent (cmd) {
spawn(UPDATE_EXE, args, { detached: true }).on('close', done) if (cmd === '--squirrel-install') {
} // App was installed. Install desktop/start menu shortcuts.
createShortcuts(function () {
// Ensure user sees install splash screen so they realize that Setup.exe actually
// installed an application and isn't the application itself.
setTimeout(function () {
app.quit()
}, 3000)
})
return true
}
export function handleEvent (cmd) { if (cmd === '--squirrel-updated') {
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated') { // App was updated. (Called on new version of app)
run([`--createShortcut=${EXE_NAME}`], app.quit) updateShortcuts(function () {
app.quit()
})
return true return true
} }
if (cmd === '--squirrel-uninstall') { if (cmd === '--squirrel-uninstall') {
// App was just uninstalled. Undo anything we did in the --squirrel-install and
// --squirrel-updated handlers
// Uninstall .torrent file and magnet link handlers // Uninstall .torrent file and magnet link handlers
handlers.uninstall() handlers.uninstall()
run([`--removeShortcut=${EXE_NAME}`], app.quit) // Remove desktop/start menu shortcuts.
// HACK: add a callback to handlers.uninstall() so we can remove this setTimeout
setTimeout(function () {
removeShortcuts(function () {
app.quit()
})
}, 1000)
return true return true
} }
if (cmd === '--squirrel-obsolete') { if (cmd === '--squirrel-obsolete') {
// App will be updated. (Called on outgoing version of app)
app.quit() app.quit()
return true return true
} }
if (cmd === '--squirrel-firstrun') {
// App is running for the first time. Do not quit, allow startup to continue.
return false
}
return false return false
} }
/**
* Spawn a command and invoke the callback when it completes with an error and
* the output from standard out.
*/
function spawn (command, args, cb) {
let stdout = ''
let error = null
let child = null
try {
child = cp.spawn(command, args)
} catch (err) {
// Spawn can throw an error
process.nextTick(function () {
cb(error, stdout)
})
return
}
child.stdout.on('data', function (data) {
stdout += data
})
child.on('error', function (processError) {
error = processError
})
child.on('close', function (code, signal) {
if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}')
if (error) error.stdout = stdout
cb(error, stdout)
})
}
/**
* Spawn the Squirrel `Update.exe` command with the given arguments and invoke
* the callback when the command completes.
*/
function spawnUpdate (args, cb) {
spawn(UPDATE_EXE, args, cb)
}
/**
* Create desktop and start menu shortcuts using the Squirrel `Update.exe`
* command.
*/
function createShortcuts (cb) {
spawnUpdate(['--createShortcut', EXE_NAME], cb)
}
/**
* Update desktop and start menu shortcuts using the Squirrel `Update.exe`
* command.
*/
function updateShortcuts (cb) {
const homeDir = os.homedir()
if (homeDir) {
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) {
const desktopShortcutExists = !err
createShortcuts(function () {
if (desktopShortcutExists) {
cb()
} else {
// Remove the unwanted desktop shortcut that was recreated
fs.unlink(desktopShortcutPath, cb)
}
})
})
} else {
createShortcuts(cb)
}
}
/**
* Remove desktop and start menu shortcuts using the Squirrel `Update.exe`
* command.
*/
function removeShortcuts (cb) {
spawnUpdate(['--removeShortcut', EXE_NAME], cb)
}

View File

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

View File

@@ -1,6 +1,21 @@
import path from 'path' module.exports = {
import config from '../config.js' disable,
import * as windows from './windows' 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 PREV_ICON = path.join(config.STATIC_PATH, 'PreviousTrackThumbnailBarButton.png')
const PLAY_ICON = path.join(config.STATIC_PATH, 'PlayThumbnailBarButton.png') const PLAY_ICON = path.join(config.STATIC_PATH, 'PlayThumbnailBarButton.png')
@@ -17,7 +32,7 @@ let buttons = []
/** /**
* Show the Windows thumbnail toolbar buttons. * Show the Windows thumbnail toolbar buttons.
*/ */
export function enable () { function enable () {
buttons = [ buttons = [
{ {
tooltip: 'Previous Track', tooltip: 'Previous Track',
@@ -41,29 +56,29 @@ export function enable () {
/** /**
* Hide the Windows thumbnail toolbar buttons. * Hide the Windows thumbnail toolbar buttons.
*/ */
export function disable () { function disable () {
buttons = [] buttons = []
update() update()
} }
export function onPlayerPause () { function onPlayerPause () {
if (!isEnabled()) return if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Play' buttons[PLAY_PAUSE].tooltip = 'Play'
buttons[PLAY_PAUSE].icon = PLAY_ICON buttons[PLAY_PAUSE].icon = PLAY_ICON
update() update()
} }
export function onPlayerPlay () { function onPlayerPlay () {
if (!isEnabled()) return if (!isEnabled()) return
buttons[PLAY_PAUSE].tooltip = 'Pause' buttons[PLAY_PAUSE].tooltip = 'Pause'
buttons[PLAY_PAUSE].icon = PAUSE_ICON buttons[PLAY_PAUSE].icon = PAUSE_ICON
update() update()
} }
export function onPlayerUpdate (state) { function onPlayerUpdate (state) {
if (!isEnabled()) return if (!isEnabled()) return
buttons[PREV].flags = [state.hasPrevious ? 'enabled' : 'disabled'] buttons[PREV].flags = [ state.hasPrevious ? 'enabled' : 'disabled' ]
buttons[NEXT].flags = [state.hasNext ? 'enabled' : 'disabled'] buttons[NEXT].flags = [ state.hasNext ? 'enabled' : 'disabled' ]
update() update()
} }

View File

@@ -1,10 +1,19 @@
import { app, Tray, Menu } from 'electron' module.exports = {
import config from '../config.js' hasTray,
import * as windows from './windows' init,
setWindowFocus
}
const electron = require('electron')
const app = electron.app
const config = require('../config')
const windows = require('./windows')
let tray let tray
export function init () { function init () {
if (process.platform === 'linux') { if (process.platform === 'linux') {
initLinux() initLinux()
} }
@@ -17,18 +26,18 @@ export function init () {
/** /**
* Returns true if there a tray icon is active. * Returns true if there a tray icon is active.
*/ */
export function hasTray () { function hasTray () {
return !!tray return !!tray
} }
export function setWindowFocus (flag) { function setWindowFocus (flag) {
if (!tray) return if (!tray) return
updateTrayMenu() updateTrayMenu()
} }
function initLinux () { function initLinux () {
checkLinuxTraySupport(err => { checkLinuxTraySupport(function (supportsTray) {
if (!err) createTray() if (supportsTray) createTray()
}) })
} }
@@ -37,20 +46,24 @@ function initWin32 () {
} }
/** /**
* Check for libappindicator support before creating tray icon. * Check for libappindicator1 support before creating tray icon
*/ */
async function checkLinuxTraySupport (cb) { function checkLinuxTraySupport (cb) {
const cp = await import('child_process') const cp = require('child_process')
// Check that libappindicator libraries are installed in system. // Check that we're on Ubuntu (or another debian system) and that we have
cp.exec('ldconfig -p | grep libappindicator', (err, stdout) => { // libappindicator1. If WebTorrent was installed from the deb file, we should
if (err) return cb(err) // always have it. If it was installed from the zip file, we might not.
cb(null) cp.exec('dpkg --get-selections libappindicator1', function (err, stdout) {
if (err) return cb(false)
// Unfortunately there's no cleaner way, as far as I can tell, to check
// whether a debian package is installed:
cb(stdout.endsWith('\tinstall\n'))
}) })
} }
function createTray () { function createTray () {
tray = new Tray(getIconPath()) tray = new electron.Tray(getIconPath())
// On Windows, left click opens the app, right click opens the context menu. // On Windows, left click opens the app, right click opens the context menu.
// On Linux, any click (left or right) opens the context menu. // On Linux, any click (left or right) opens the context menu.
@@ -61,7 +74,7 @@ function createTray () {
} }
function updateTrayMenu () { function updateTrayMenu () {
const contextMenu = Menu.buildFromTemplate(getMenuTemplate()) const contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
tray.setContextMenu(contextMenu) tray.setContextMenu(contextMenu)
} }

View File

@@ -1,10 +1,13 @@
import electron from '../../electron.cjs' module.exports = {
import get from 'simple-get' init
import config from '../config.js' }
import log from './log.js'
import * as windows from './windows/index.js'
const { autoUpdater } = electron const electron = require('electron')
const get = require('simple-get')
const config = require('../config')
const log = require('./log')
const windows = require('./windows')
const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL + const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
'?version=' + config.APP_VERSION + '?version=' + config.APP_VERSION +
@@ -19,8 +22,6 @@ function init () {
} }
} }
export default { init }
// The Electron auto-updater does not support Linux yet, so manually check for // The Electron auto-updater does not support Linux yet, so manually check for
// updates and show the user a modal notification. // updates and show the user a modal notification.
function initLinux () { function initLinux () {
@@ -46,31 +47,31 @@ function onResponse (err, res, data) {
} }
function initDarwinWin32 () { function initDarwinWin32 () {
autoUpdater.on( electron.autoUpdater.on(
'error', 'error',
(err) => log(`Update error: ${err.message}`) (err) => log.error(`Update error: ${err.message}`)
) )
autoUpdater.on( electron.autoUpdater.on(
'checking-for-update', 'checking-for-update',
() => log('Checking for update') () => log('Checking for update')
) )
autoUpdater.on( electron.autoUpdater.on(
'update-available', 'update-available',
() => log('Update available') () => log('Update available')
) )
autoUpdater.on( electron.autoUpdater.on(
'update-not-available', 'update-not-available',
() => log('No update available') () => log('No update available')
) )
autoUpdater.on( electron.autoUpdater.on(
'update-downloaded', 'update-downloaded',
(e, notes, name, date, url) => log(`Update downloaded: ${name}: ${url}`) (e, notes, name, date, url) => log(`Update downloaded: ${name}: ${url}`)
) )
autoUpdater.setFeedURL({ url: AUTO_UPDATE_URL }) electron.autoUpdater.setFeedURL(AUTO_UPDATE_URL)
autoUpdater.checkForUpdates() electron.autoUpdater.checkForUpdates()
} }

View File

@@ -1,9 +1,15 @@
import { app } from 'electron' module.exports = {
init
}
const electron = require('electron')
const app = electron.app
/** /**
* Add a user task menu to the app icon on right-click. (Windows) * Add a user task menu to the app icon on right-click. (Windows)
*/ */
export function init () { function init () {
if (process.platform !== 'win32') return if (process.platform !== 'win32') return
app.setUserTasks(getUserTasks()) app.setUserTasks(getUserTasks())
} }

View File

@@ -1,12 +1,11 @@
import config from '../../config.js' const about = module.exports = {
import electron from '../../../electron.cjs'
import * as RemoteMain from '@electron/remote/main/index.js'
export const about = {
init, init,
win: null win: null
} }
const config = require('../../config')
const electron = require('electron')
function init () { function init () {
if (about.win) { if (about.win) {
return about.win.show() return about.win.show()
@@ -16,7 +15,7 @@ function init () {
backgroundColor: '#ECECEC', backgroundColor: '#ECECEC',
center: true, center: true,
fullscreen: false, fullscreen: false,
height: 250, height: 170,
icon: getIconPath(), icon: getIconPath(),
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
@@ -25,27 +24,19 @@ function init () {
skipTaskbar: true, skipTaskbar: true,
title: 'About ' + config.APP_WINDOW_TITLE, title: 'About ' + config.APP_WINDOW_TITLE,
useContentSize: true, useContentSize: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableBlinkFeatures: 'AudioVideoTracks',
enableRemoteModule: true,
backgroundThrottling: false
},
width: 300 width: 300
}) })
RemoteMain.enable(win.webContents)
win.loadURL(config.WINDOW_ABOUT) win.loadURL(config.WINDOW_ABOUT)
win.once('ready-to-show', () => { // No menu on the About window
win.setMenu(null)
win.once('ready-to-show', function () {
win.show() win.show()
// No menu on the About window
// Hack: BrowserWindow removeMenu method not working on electron@7
// https://github.com/electron/electron/issues/21088
win.setMenuBarVisibility(false)
}) })
win.once('closed', () => { win.once('closed', function () {
about.win = null about.win = null
}) })
} }

View File

@@ -1,3 +1,3 @@
export * from './about.js' exports.about = require('./about')
export * from './main.js' exports.main = require('./main')
export * from './webtorrent.js' exports.webtorrent = require('./webtorrent')

View File

@@ -1,13 +1,4 @@
import debounce from 'debounce' const main = module.exports = {
import electron from '../../../electron.cjs'
import config from '../../config.js'
import log from '../log.js'
import menu from '../menu.js'
import * as RemoteMain from '@electron/remote/main/index.js'
const { app, BrowserWindow, screen } = electron
export const main = {
dispatch, dispatch,
hide, hide,
init, init,
@@ -23,6 +14,15 @@ export const main = {
win: null win: null
} }
const electron = require('electron')
const debounce = require('debounce')
const app = electron.app
const config = require('../../config')
const log = require('../log')
const menu = require('../menu')
function init (state, options) { function init (state, options) {
if (main.win) { if (main.win) {
return main.win.show() return main.win.show()
@@ -30,8 +30,9 @@ function init (state, options) {
const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds) const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds)
const win = main.win = new BrowserWindow({ const win = main.win = new electron.BrowserWindow({
backgroundColor: '#282828', backgroundColor: '#282828',
backgroundThrottling: false, // do not throttle animations/timers when page is background
darkTheme: true, // Forces dark theme (GTK+3) darkTheme: true, // Forces dark theme (GTK+3)
height: initialBounds.height, height: initialBounds.height,
icon: getIconPath(), // Window icon (Windows, Linux) icon: getIconPath(), // Window icon (Windows, Linux)
@@ -39,21 +40,13 @@ function init (state, options) {
minWidth: config.WINDOW_MIN_WIDTH, minWidth: config.WINDOW_MIN_WIDTH,
show: false, show: false,
title: config.APP_WINDOW_TITLE, title: config.APP_WINDOW_TITLE,
titleBarStyle: 'hiddenInset', // Hide title bar (Mac) titleBarStyle: 'hidden-inset', // Hide title bar (Mac)
useContentSize: true, // Specify web page size without OS chrome useContentSize: true, // Specify web page size without OS chrome
width: initialBounds.width, width: initialBounds.width,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableBlinkFeatures: 'AudioVideoTracks',
enableRemoteModule: true,
backgroundThrottling: false
},
x: initialBounds.x, x: initialBounds.x,
y: initialBounds.y y: initialBounds.y
}) })
RemoteMain.enable(win.webContents)
console.log(config.WINDOW_MAIN)
win.loadURL(config.WINDOW_MAIN) win.loadURL(config.WINDOW_MAIN)
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
@@ -64,11 +57,11 @@ function init (state, options) {
win.setSheetOffset(config.UI_HEADER_HEIGHT) win.setSheetOffset(config.UI_HEADER_HEIGHT)
} }
win.webContents.on('dom-ready', () => { win.webContents.on('dom-ready', function () {
menu.onToggleFullScreen(main.win.isFullScreen()) menu.onToggleFullScreen(main.win.isFullScreen())
}) })
win.webContents.on('will-navigate', (e) => { win.webContents.on('will-navigate', (e, url) => {
// Prevent drag-and-drop from navigating the Electron window, which can happen // Prevent drag-and-drop from navigating the Electron window, which can happen
// before our drag-and-drop handlers have been initialized. // before our drag-and-drop handlers have been initialized.
e.preventDefault() e.preventDefault()
@@ -80,30 +73,30 @@ function init (state, options) {
win.on('hide', onWindowBlur) win.on('hide', onWindowBlur)
win.on('show', onWindowFocus) win.on('show', onWindowFocus)
win.on('enter-full-screen', () => { win.on('enter-full-screen', function () {
menu.onToggleFullScreen(true) menu.onToggleFullScreen(true)
send('fullscreenChanged', true) send('fullscreenChanged', true)
win.setMenuBarVisibility(false) win.setMenuBarVisibility(false)
}) })
win.on('leave-full-screen', () => { win.on('leave-full-screen', function () {
menu.onToggleFullScreen(false) menu.onToggleFullScreen(false)
send('fullscreenChanged', false) send('fullscreenChanged', false)
win.setMenuBarVisibility(true) win.setMenuBarVisibility(true)
}) })
win.on('move', debounce(e => { win.on('move', debounce(function (e) {
send('windowBoundsChanged', e.sender.getBounds()) send('windowBoundsChanged', e.sender.getBounds())
}, 1000)) }, 1000))
win.on('resize', debounce(e => { win.on('resize', debounce(function (e) {
send('windowBoundsChanged', e.sender.getBounds()) send('windowBoundsChanged', e.sender.getBounds())
}, 1000)) }, 1000))
win.on('close', async e => { win.on('close', function (e) {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
const { hasTray } = await import('../tray.js') const tray = require('../tray')
if (!hasTray()) { if (!tray.hasTray()) {
app.quit() app.quit()
return return
} }
@@ -145,29 +138,37 @@ function setAspectRatio (aspectRatio) {
function setBounds (bounds, maximize) { function setBounds (bounds, maximize) {
// Do nothing in fullscreen // Do nothing in fullscreen
if (!main.win || main.win.isFullScreen()) { if (!main.win || main.win.isFullScreen()) {
log('setBounds: not setting bounds because already in full screen mode') log('setBounds: not setting bounds because we\'re in full screen')
return return
} }
// Maximize or minimize, if the second argument is present // Maximize or minimize, if the second argument is present
if (maximize === true && !main.win.isMaximized()) { let willBeMaximized
log('setBounds: maximizing') if (maximize === true) {
main.win.maximize() if (!main.win.isMaximized()) {
} else if (maximize === false && main.win.isMaximized()) { log('setBounds: maximizing')
log('setBounds: minimizing') main.win.maximize()
main.win.unmaximize() }
willBeMaximized = true
} else if (maximize === false) {
if (main.win.isMaximized()) {
log('setBounds: unmaximizing')
main.win.unmaximize()
}
willBeMaximized = false
} else {
willBeMaximized = main.win.isMaximized()
} }
const willBeMaximized = typeof maximize === 'boolean' ? maximize : main.win.isMaximized()
// Assuming we're not maximized or maximizing, set the window size // Assuming we're not maximized or maximizing, set the window size
if (!willBeMaximized) { if (!willBeMaximized) {
log(`setBounds: setting bounds to ${JSON.stringify(bounds)}`) log('setBounds: setting bounds to ' + JSON.stringify(bounds))
if (bounds.x === null && bounds.y === null) { if (bounds.x === null && bounds.y === null) {
// X and Y not specified? By default, center on current screen // X and Y not specified? By default, center on current screen
const scr = screen.getDisplayMatching(main.win.getBounds()) const scr = electron.screen.getDisplayMatching(main.win.getBounds())
bounds.x = Math.round(scr.bounds.x + (scr.bounds.width / 2) - (bounds.width / 2)) bounds.x = Math.round(scr.bounds.x + scr.bounds.width / 2 - bounds.width / 2)
bounds.y = Math.round(scr.bounds.y + (scr.bounds.height / 2) - (bounds.height / 2)) bounds.y = Math.round(scr.bounds.y + scr.bounds.height / 2 - bounds.height / 2)
log(`setBounds: centered to ${JSON.stringify(bounds)}`) log('setBounds: centered to ' + JSON.stringify(bounds))
} }
// Resize the window's content area (so window border doesn't need to be taken // Resize the window's content area (so window border doesn't need to be taken
// into account) // into account)
@@ -216,7 +217,7 @@ function toggleDevTools () {
if (main.win.webContents.isDevToolsOpened()) { if (main.win.webContents.isDevToolsOpened()) {
main.win.webContents.closeDevTools() main.win.webContents.closeDevTools()
} else { } else {
main.win.webContents.openDevTools({ mode: 'detach' }) main.win.webContents.openDevTools({ detach: true })
} }
} }

View File

@@ -1,8 +1,4 @@
import electron from '../../../electron.cjs' const webtorrent = module.exports = {
import config from '../../config.js'
import * as RemoteMain from '@electron/remote/main/index.js'
export const webtorrent = {
init, init,
send, send,
show, show,
@@ -10,9 +6,14 @@ export const webtorrent = {
win: null win: null
} }
const electron = require('electron')
const config = require('../../config')
function init () { function init () {
const win = webtorrent.win = new electron.BrowserWindow({ const win = webtorrent.win = new electron.BrowserWindow({
backgroundColor: '#1E1E1E', backgroundColor: '#1E1E1E',
backgroundThrottling: false, // do not throttle animations/timers when page is background
center: true, center: true,
fullscreen: false, fullscreen: false,
fullscreenable: false, fullscreenable: false,
@@ -24,19 +25,13 @@ function init () {
skipTaskbar: true, skipTaskbar: true,
title: 'webtorrent-hidden-window', title: 'webtorrent-hidden-window',
useContentSize: true, useContentSize: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableBlinkFeatures: 'AudioVideoTracks',
enableRemoteModule: true,
backgroundThrottling: false
},
width: 150 width: 150
}) })
RemoteMain.enable(win.webContents)
win.loadURL(config.WINDOW_WEBTORRENT) win.loadURL(config.WINDOW_WEBTORRENT)
// Prevent killing the WebTorrent process // Prevent killing the WebTorrent process
win.on('close', e => { win.on('close', function (e) {
if (electron.app.isQuitting) { if (electron.app.isQuitting) {
return return
} }
@@ -61,6 +56,6 @@ function toggleDevTools () {
webtorrent.win.webContents.closeDevTools() webtorrent.win.webContents.closeDevTools()
webtorrent.win.hide() webtorrent.win.hide()
} else { } else {
webtorrent.win.webContents.openDevTools({ mode: 'detach' }) webtorrent.win.webContents.openDevTools({ detach: true })
} }
} }

View File

@@ -1,7 +1,8 @@
import * as React from 'react' const React = require('react')
import { dispatcher } from '../lib/dispatcher.js'
export default class CreateTorrentErrorPage extends React.Component { const {dispatcher} = require('../lib/dispatcher')
module.exports = class CreateTorrentErrorPage extends React.Component {
render () { render () {
return ( return (
<div className='create-torrent'> <div className='create-torrent'>

View File

@@ -1,30 +0,0 @@
import * as React from 'react'
import ModalOKCancel from './modal-ok-cancel.js'
import { dispatch, dispatcher } from '../lib/dispatcher.js'
export default class DeleteAllTorrentsModal extends React.Component {
render () {
const { state: { modal: { deleteData } } } = this.props
const message = deleteData
? 'Are you sure you want to remove all the torrents from the list and delete the data files?'
: 'Are you sure you want to remove all the torrents from the list?'
const buttonText = 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('deleteAllTorrents', deleteData)
dispatch('exitModal')
}
}
}

View File

@@ -1,30 +1,27 @@
import * as React from 'react' const React = require('react')
import { dispatcher } from '../lib/dispatcher.js'
export default class Header extends React.Component { const {dispatcher} = require('../lib/dispatcher')
class Header extends React.Component {
render () { render () {
const loc = this.props.state.location const loc = this.props.state.location
return ( return (
<div <div className='header'
className='header'
onMouseMove={dispatcher('mediaMouseMoved')} onMouseMove={dispatcher('mediaMouseMoved')}
onMouseEnter={dispatcher('mediaControlsMouseEnter')} onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')} onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
>
{this.getTitle()} {this.getTitle()}
<div className='nav left float-left'> <div className='nav left float-left'>
<i <i
className={'icon back ' + (loc.hasBack() ? '' : 'disabled')} className={'icon back ' + (loc.hasBack() ? '' : 'disabled')}
title='Back' title='Back'
onClick={dispatcher('back')} onClick={dispatcher('back')}>
>
chevron_left chevron_left
</i> </i>
<i <i
className={'icon forward ' + (loc.hasForward() ? '' : 'disabled')} className={'icon forward ' + (loc.hasForward() ? '' : 'disabled')}
title='Forward' title='Forward'
onClick={dispatcher('forward')} onClick={dispatcher('forward')}>
>
chevron_right chevron_right
</i> </i>
</div> </div>
@@ -48,10 +45,11 @@ export default class Header extends React.Component {
<i <i
className='icon add' className='icon add'
title='Add torrent' title='Add torrent'
onClick={dispatcher('openFiles')} onClick={dispatcher('openFiles')}>
>
add add
</i> </i>
) )
} }
} }
module.exports = Header

View File

@@ -1,11 +1,11 @@
import * as React from 'react' const React = require('react')
import PropTypes from 'prop-types'
import * as colors from 'material-ui/styles/colors'
export default class Heading extends React.Component { const colors = require('material-ui/styles/colors')
class Heading extends React.Component {
static get propTypes () { static get propTypes () {
return { return {
level: PropTypes.number level: React.PropTypes.number
} }
} }
@@ -30,3 +30,5 @@ export default class Heading extends React.Component {
) )
} }
} }
module.exports = Heading

View File

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

View File

@@ -1,14 +1,10 @@
import * as React from 'react' const React = require('react')
import textField from 'material-ui/TextField' const TextField = require('material-ui/TextField').default
import electron from 'electron'
import ModalOKCancel from './modal-ok-cancel.js'
import { dispatch, dispatcher } from '../lib/dispatcher.js'
import { isMagnetLink } from '../lib/torrent-player.js'
const TextField = textField.default const ModalOKCancel = require('./modal-ok-cancel')
const { clipboard } = electron const {dispatch, dispatcher} = require('../lib/dispatcher')
export default class OpenTorrentAddressModal extends React.Component { module.exports = class OpenTorrentAddressModal extends React.Component {
render () { render () {
return ( return (
<div className='open-torrent-address-modal'> <div className='open-torrent-address-modal'>
@@ -19,27 +15,19 @@ export default class OpenTorrentAddressModal extends React.Component {
className='control' className='control'
ref={(c) => { this.torrentURL = c }} ref={(c) => { this.torrentURL = c }}
fullWidth fullWidth
onKeyDown={handleKeyDown.bind(this)} onKeyDown={handleKeyDown.bind(this)} />
/>
</div> </div>
<ModalOKCancel <ModalOKCancel
cancelText='CANCEL' cancelText='CANCEL'
onCancel={dispatcher('exitModal')} onCancel={dispatcher('exitModal')}
okText='OK' okText='OK'
onOK={handleOK.bind(this)} onOK={handleOK.bind(this)} />
/>
</div> </div>
) )
} }
componentDidMount () { componentDidMount () {
this.torrentURL.input.focus() this.torrentURL.input.focus()
const clipboardContent = clipboard.readText()
if (isMagnetLink(clipboardContent)) {
this.torrentURL.input.value = clipboardContent
this.torrentURL.input.select()
}
} }
} }

View File

@@ -1,25 +1,25 @@
import path from 'path' const colors = require('material-ui/styles/colors')
import * as colors from 'material-ui/styles/colors' const electron = require('electron')
import remote from '@electron/remote' const React = require('react')
import * as React from 'react'
import PropTypes from 'prop-types' const remote = electron.remote
import raisedButton from 'material-ui/RaisedButton'
import textField from 'material-ui/TextField' const RaisedButton = require('material-ui/RaisedButton').default
const TextField = require('material-ui/TextField').default
const RaisedButton = raisedButton.default
const TextField = textField.default
// Lets you pick a file or directory. // Lets you pick a file or directory.
// Uses the system Open File dialog. // Uses the system Open File dialog.
// You can't edit the text field directly. // You can't edit the text field directly.
export default class PathSelector extends React.Component { class PathSelector extends React.Component {
static propTypes () { static get propTypes () {
return { return {
className: PropTypes.string, className: React.PropTypes.string,
dialog: PropTypes.object, dialog: React.PropTypes.object,
id: PropTypes.string, displayValue: React.PropTypes.string,
onChange: PropTypes.func, id: React.PropTypes.string,
title: PropTypes.string.isRequired, onChange: React.PropTypes.func,
value: PropTypes.string title: React.PropTypes.string.isRequired,
value: React.PropTypes.string
} }
} }
@@ -30,13 +30,18 @@ export default class PathSelector extends React.Component {
handleClick () { handleClick () {
const opts = Object.assign({ const opts = Object.assign({
defaultPath: path.dirname(this.props.value || ''), defaultPath: this.props.value,
properties: ['openFile', 'openDirectory'] properties: [ 'openFile', 'openDirectory' ]
}, this.props.dialog) }, this.props.dialog)
const filenames = remote.dialog.showOpenDialogSync(remote.getCurrentWindow(), opts) remote.dialog.showOpenDialog(
if (!Array.isArray(filenames)) return remote.getCurrentWindow(),
this.props.onChange && this.props.onChange(filenames[0]) opts,
(filenames) => {
if (!Array.isArray(filenames)) return
this.props.onChange && this.props.onChange(filenames[0])
}
)
} }
render () { render () {
@@ -59,7 +64,7 @@ export default class PathSelector extends React.Component {
const textFieldStyle = { const textFieldStyle = {
flex: '1' flex: '1'
} }
const text = this.props.value || '' const text = this.props.displayValue || this.props.value
const buttonStyle = { const buttonStyle = {
marginLeft: 10 marginLeft: 10
} }
@@ -69,15 +74,13 @@ export default class PathSelector extends React.Component {
<div className='label' style={labelStyle}> <div className='label' style={labelStyle}>
{this.props.title}: {this.props.title}:
</div> </div>
<TextField <TextField className='control' disabled id={id} value={text}
className='control' disabled id={id} value={text} inputStyle={textareaStyle} style={textFieldStyle} />
inputStyle={textareaStyle} style={textFieldStyle} <RaisedButton className='control' label='Change' onClick={this.handleClick}
/> style={buttonStyle} />
<RaisedButton
className='control' label='Change' onClick={this.handleClick}
style={buttonStyle}
/>
</div> </div>
) )
} }
} }
module.exports = PathSelector

View File

@@ -1,8 +1,9 @@
import * as React from 'react' const React = require('react')
import ModalOKCancel from './modal-ok-cancel.js'
import { dispatch, dispatcher } from '../lib/dispatcher.js'
export default class RemoveTorrentModal extends React.Component { const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch, dispatcher} = require('../lib/dispatcher')
module.exports = class RemoveTorrentModal extends React.Component {
render () { render () {
const state = this.props.state const state = this.props.state
const message = state.modal.deleteData const message = state.modal.deleteData
@@ -17,8 +18,7 @@ export default class RemoveTorrentModal extends React.Component {
cancelText='CANCEL' cancelText='CANCEL'
onCancel={dispatcher('exitModal')} onCancel={dispatcher('exitModal')}
okText={buttonText} okText={buttonText}
onOK={handleRemove} onOK={handleRemove} />
/>
</div> </div>
) )

View File

@@ -1,15 +1,13 @@
import * as React from 'react' const React = require('react')
import PropTypes from 'prop-types'
import raisedButton from 'material-ui/RaisedButton'
const RaisedButton = raisedButton.default const RaisedButton = require('material-ui/RaisedButton').default
export default class ShowMore extends React.Component { class ShowMore extends React.Component {
static get propTypes () { static get propTypes () {
return { return {
defaultExpanded: PropTypes.bool, defaultExpanded: React.PropTypes.bool,
hideLabel: PropTypes.string, hideLabel: React.PropTypes.string,
showLabel: PropTypes.string showLabel: React.PropTypes.string
} }
} }
@@ -22,9 +20,11 @@ export default class ShowMore extends React.Component {
constructor (props) { constructor (props) {
super(props) super(props)
this.state = { this.state = {
expanded: !!this.props.defaultExpanded expanded: !!this.props.defaultExpanded
} }
this.handleClick = this.handleClick.bind(this) this.handleClick = this.handleClick.bind(this)
} }
@@ -44,9 +44,10 @@ export default class ShowMore extends React.Component {
<RaisedButton <RaisedButton
className='control' className='control'
onClick={this.handleClick} onClick={this.handleClick}
label={label} label={label} />
/>
</div> </div>
) )
} }
} }
module.exports = ShowMore

View File

@@ -1,9 +1,10 @@
import * as React from 'react' const React = require('react')
import { shell } from 'electron' const electron = require('electron')
import ModalOKCancel from './modal-ok-cancel.js'
import { dispatcher } from '../lib/dispatcher.js'
export default class UnsupportedMediaModal extends React.Component { const ModalOKCancel = require('./modal-ok-cancel')
const {dispatcher} = require('../lib/dispatcher')
module.exports = class UnsupportedMediaModal extends React.Component {
render () { render () {
const state = this.props.state const state = this.props.state
const err = state.modal.error const err = state.modal.error
@@ -27,15 +28,14 @@ export default class UnsupportedMediaModal extends React.Component {
cancelText='CANCEL' cancelText='CANCEL'
onCancel={dispatcher('backToList')} onCancel={dispatcher('backToList')}
okText={actionText} okText={actionText}
onOK={onAction} onOK={onAction} />
/>
<p className='error-text'>{errorMessage}</p> <p className='error-text'>{errorMessage}</p>
</div> </div>
) )
} }
onInstall () { onInstall () {
shell.openExternal('https://www.videolan.org/vlc/') electron.shell.openExternal('http://www.videolan.org/vlc/')
// TODO: dcposch send a dispatch rather than modifying state directly // TODO: dcposch send a dispatch rather than modifying state directly
const state = this.props.state const state = this.props.state

View File

@@ -1,9 +1,10 @@
import * as React from 'react' const React = require('react')
import { shell } from 'electron' const electron = require('electron')
import ModalOKCancel from './modal-ok-cancel.js'
import { dispatch } from '../lib/dispatcher.js'
export default class UpdateAvailableModal extends React.Component { const ModalOKCancel = require('./modal-ok-cancel')
const {dispatch} = require('../lib/dispatcher')
module.exports = class UpdateAvailableModal extends React.Component {
render () { render () {
const state = this.props.state const state = this.props.state
return ( return (
@@ -17,14 +18,12 @@ export default class UpdateAvailableModal extends React.Component {
cancelText='SKIP THIS RELEASE' cancelText='SKIP THIS RELEASE'
onCancel={handleSkip} onCancel={handleSkip}
okText='SHOW DOWNLOAD PAGE' okText='SHOW DOWNLOAD PAGE'
onOK={handleShow} onOK={handleShow} />
/>
</div> </div>
) )
function handleShow () { function handleShow () {
// TODO: use the GitHub urls from config.js electron.shell.openExternal('https://github.com/feross/webtorrent-desktop/releases')
shell.openExternal('https://github.com/webtorrent/webtorrent-desktop/releases')
dispatch('exitModal') dispatch('exitModal')
} }

View File

@@ -1,17 +0,0 @@
import { dispatch } from '../lib/dispatcher.js'
export default class AudioTracksController {
constructor (state) {
this.state = state
}
selectAudioTrack (ix) {
this.state.playing.audioTracks.selectedIndex = ix
dispatch('skip', 0.2) // HACK: hardcoded seek value for smooth audio change
}
toggleAudioTracksMenu () {
const audioTracks = this.state.playing.audioTracks
audioTracks.showMenu = !audioTracks.showMenu
}
}

View File

@@ -1,13 +0,0 @@
import { ipcRenderer } from 'electron'
export default class FolderWatcherController {
start () {
console.log('-- IPC: start folder watcher')
ipcRenderer.send('startFolderWatcher')
}
stop () {
console.log('-- IPC: stop folder watcher')
ipcRenderer.send('stopFolderWatcher')
}
}

View File

@@ -1,8 +1,10 @@
import { ipcRenderer } from 'electron' const {ipcRenderer} = require('electron')
import telemetry from '../lib/telemetry.js' const telemetry = require('../lib/telemetry')
import Playlist from '../lib/playlist.js' const Playlist = require('../lib/playlist')
export default class MediaController { // Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc)
module.exports = class MediaController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
@@ -21,10 +23,10 @@ export default class MediaController {
telemetry.logPlayAttempt('error') telemetry.logPlayAttempt('error')
state.playing.location = 'error' state.playing.location = 'error'
ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath) ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath)
ipcRenderer.once('checkForExternalPlayer', (e, isInstalled) => { ipcRenderer.once('checkForExternalPlayer', function (e, isInstalled) {
state.modal = { state.modal = {
id: 'unsupported-media-modal', id: 'unsupported-media-modal',
error, error: error,
externalPlayerInstalled: isInstalled externalPlayerInstalled: isInstalled
} }
}) })
@@ -54,7 +56,7 @@ export default class MediaController {
const state = this.state const state = this.state
state.playing.location = 'external' state.playing.location = 'external'
const onServerRunning = () => { const onServerRunning = function () {
state.playing.isReady = true state.playing.isReady = true
telemetry.logPlayAttempt('external') telemetry.logPlayAttempt('external')

View File

@@ -1,29 +1,21 @@
import { ipcRenderer } from 'electron' const electron = require('electron')
import path from 'path' const path = require('path')
import Cast from '../lib/cast.js'
import { dispatch } from '../lib/dispatcher.js' const Cast = require('../lib/cast')
import telemetry from '../lib/telemetry.js' const {dispatch} = require('../lib/dispatcher')
import { UnplayableFileError, UnplayableTorrentError } from '../lib/errors.js' const telemetry = require('../lib/telemetry')
import sound from '../lib/sound.js' const {UnplayableFileError, UnplayableTorrentError} = require('../lib/errors')
import TorrentPlayer from '../lib/torrent-player.js' const sound = require('../lib/sound')
import TorrentSummary from '../lib/torrent-summary.js' const TorrentPlayer = require('../lib/torrent-player')
import Playlist from '../lib/playlist.js' const TorrentSummary = require('../lib/torrent-summary')
import State from '../lib/state.js' const Playlist = require('../lib/playlist')
// Checks whether we are connected and already casting const State = require('../lib/state')
// Returns false if we not casting (state.playing.location === 'local')
// or if we're trying to connect but haven't yet ('chromecast-pending', etc) const ipcRenderer = electron.ipcRenderer
function isCasting (state) {
return state.playing.location === 'chromecast' || // Controls playback of torrents and files within torrents
state.playing.location === 'airplay' || // both local (<video>,<audio>,external player) and remote (cast)
state.playing.location === 'dlna' module.exports = class PlaybackController {
}
function restoreBounds (state) {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}
export default class PlaybackController {
constructor (state, config, update) { constructor (state, config, update) {
this.state = state this.state = state
this.config = config this.config = config
@@ -35,11 +27,11 @@ export default class PlaybackController {
// * Stream, if not already fully downloaded // * Stream, if not already fully downloaded
// * If no file index is provided, restore the most recently viewed file or autoplay the first // * If no file index is provided, restore the most recently viewed file or autoplay the first
playFile (infoHash, index /* optional */) { playFile (infoHash, index /* optional */) {
this.pauseActiveTorrents(infoHash)
const state = this.state const state = this.state
if (state.location.url() === 'player') { if (state.location.url() === 'player') {
this.updatePlayer(infoHash, index, false, (err) => { this.updatePlayer(infoHash, index, false, (err) => {
if (err) { dispatch('error', err) } else { this.play() } if (err) dispatch('error', err)
else this.play()
}) })
} else { } else {
let initialized = false let initialized = false
@@ -47,10 +39,13 @@ export default class PlaybackController {
url: 'player', url: 'player',
setup: (cb) => { setup: (cb) => {
const torrentSummary = TorrentSummary.getByKey(state, infoHash) const torrentSummary = TorrentSummary.getByKey(state, infoHash)
if (index === undefined || initialized) index = torrentSummary.mostRecentFileIndex if (index === undefined || initialized) index = torrentSummary.mostRecentFileIndex
if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable) if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable)
if (index === undefined) return cb(new UnplayableTorrentError()) if (index === undefined) return cb(new UnplayableTorrentError())
initialized = true initialized = true
this.openPlayer(infoHash, index, (err) => { this.openPlayer(infoHash, index, (err) => {
if (!err) this.play() if (!err) this.play()
cb(err) cb(err)
@@ -58,47 +53,46 @@ export default class PlaybackController {
}, },
destroy: () => this.closePlayer() destroy: () => this.closePlayer()
}, (err) => { }, (err) => {
if (err) { dispatch('error', err) } if (err) dispatch('error', err)
}) })
} }
} }
// Open a file in OS default app. // Open a file in OS default app.
openPath (infoHash, index) { openItem (infoHash, index) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const filePath = path.join(torrentSummary.path, torrentSummary.files[index].path) const filePath = path.join(
ipcRenderer.send('openPath', filePath) torrentSummary.path,
torrentSummary.files[index].path)
ipcRenderer.send('openItem', filePath)
} }
// Toggle (play or pause) the currently playing media // Toggle (play or pause) the currently playing media
playPause () { playPause () {
const state = this.state const state = this.state
if (state.location.url() !== 'player') return if (state.location.url() !== 'player') return
// force rerendering if window is hidden, // force rerendering if window is hidden,
// in order to bypass `raf` and play/pause media immediately // in order to bypass `raf` and play/pause media immediately
const mediaTag = document.querySelector('video,audio') const mediaTag = document.querySelector('video,audio')
if (!state.window.isVisible && mediaTag) { if (!state.window.isVisible && mediaTag) {
if (state.playing.isPaused) { mediaTag.play() } else { mediaTag.pause() } if (state.playing.isPaused) mediaTag.play()
else mediaTag.pause()
} }
if (state.playing.isPaused) { this.play() } else { this.pause() }
}
pauseActiveTorrents (infoHash) { if (state.playing.isPaused) this.play()
// Playback Priority: pause all active torrents if needed. else this.pause()
if (!this.state.saved.prefs.highestPlaybackPriority) return
// Do not pause active torrents if playing a fully downloaded torrent.
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'seeding') return
dispatch('prioritizeTorrent', infoHash)
} }
// Play next file in list (if any) // Play next file in list (if any)
nextTrack () { nextTrack () {
const state = this.state const state = this.state
if (Playlist.hasNext(state) && state.playing.location !== 'external') { if (Playlist.hasNext(state) && state.playing.location !== 'external') {
this.updatePlayer(state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => { this.updatePlayer(
if (err) { dispatch('error', err) } else { this.play() } state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => {
}) if (err) dispatch('error', err)
else this.play()
})
} }
} }
@@ -106,9 +100,11 @@ export default class PlaybackController {
previousTrack () { previousTrack () {
const state = this.state const state = this.state
if (Playlist.hasPrevious(state) && state.playing.location !== 'external') { if (Playlist.hasPrevious(state) && state.playing.location !== 'external') {
this.updatePlayer(state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => { this.updatePlayer(
if (err) { dispatch('error', err) } else { this.play() } state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => {
}) if (err) dispatch('error', err)
else this.play()
})
} }
} }
@@ -145,21 +141,8 @@ export default class PlaybackController {
console.error('Tried to skip to a non-finite time ' + time) console.error('Tried to skip to a non-finite time ' + time)
return console.trace() return console.trace()
} }
if (isCasting(this.state)) { Cast.seek(time) } else { this.state.playing.jumpToTime = time } if (isCasting(this.state)) Cast.seek(time)
} else this.state.playing.jumpToTime = time
// Show video preview
preview (x) {
if (!Number.isFinite(x)) {
console.error('Tried to preview a non-finite position ' + x)
return console.trace()
}
this.state.playing.previewXCoord = x
}
// Hide video preview
clearPreview () {
this.state.playing.previewXCoord = null
} }
// Change playback speed. 1 = faster, -1 = slower // Change playback speed. 1 = faster, -1 = slower
@@ -197,6 +180,7 @@ export default class PlaybackController {
setVolume (volume) { setVolume (volume) {
// check if its in [0.0 - 1.0] range // check if its in [0.0 - 1.0] range
volume = Math.max(0, Math.min(1, volume)) volume = Math.max(0, Math.min(1, volume))
const state = this.state const state = this.state
if (isCasting(state)) { if (isCasting(state)) {
Cast.setVolume(volume) Cast.setVolume(volume)
@@ -213,6 +197,7 @@ export default class PlaybackController {
showOrHidePlayerControls () { showOrHidePlayerControls () {
const state = this.state const state = this.state
const hideControls = state.shouldHidePlayerControls() const hideControls = state.shouldHidePlayerControls()
if (hideControls !== state.playing.hideControls) { if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls state.playing.hideControls = hideControls
return true return true
@@ -224,10 +209,13 @@ export default class PlaybackController {
openPlayer (infoHash, index, cb) { openPlayer (infoHash, index, cb) {
const state = this.state const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash) const torrentSummary = TorrentSummary.getByKey(state, infoHash)
state.playing.infoHash = torrentSummary.infoHash state.playing.infoHash = torrentSummary.infoHash
state.playing.isReady = false state.playing.isReady = false
// update UI to show pending playback // update UI to show pending playback
sound.play('PLAY') sound.play('PLAY')
this.startServer(torrentSummary) this.startServer(torrentSummary)
ipcRenderer.send('onPlayerOpen') ipcRenderer.send('onPlayerOpen')
this.updatePlayer(infoHash, index, true, cb) this.updatePlayer(infoHash, index, true, cb)
@@ -236,12 +224,15 @@ export default class PlaybackController {
// Starts WebTorrent server for media streaming // Starts WebTorrent server for media streaming
startServer (torrentSummary) { startServer (torrentSummary) {
const state = this.state const state = this.state
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
dispatch('startTorrentingSummary', torrentSummary.torrentKey) dispatch('startTorrentingSummary', torrentSummary.torrentKey)
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, () => onTorrentReady()) ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
() => onTorrentReady())
} else { } else {
onTorrentReady() onTorrentReady()
} }
function onTorrentReady () { function onTorrentReady () {
ipcRenderer.send('wt-start-server', torrentSummary.infoHash) ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
ipcRenderer.once('wt-server-running', () => { state.playing.isReady = true }) ipcRenderer.once('wt-server-running', () => { state.playing.isReady = true })
@@ -251,23 +242,25 @@ export default class PlaybackController {
// Called each time the current file changes // Called each time the current file changes
updatePlayer (infoHash, index, resume, cb) { updatePlayer (infoHash, index, resume, cb) {
const state = this.state const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash) const torrentSummary = TorrentSummary.getByKey(state, infoHash)
const fileSummary = torrentSummary.files[index] const fileSummary = torrentSummary.files[index]
if (!TorrentPlayer.isPlayable(fileSummary)) { if (!TorrentPlayer.isPlayable(fileSummary)) {
torrentSummary.mostRecentFileIndex = undefined torrentSummary.mostRecentFileIndex = undefined
return cb(new UnplayableFileError()) return cb(new UnplayableFileError())
} }
torrentSummary.mostRecentFileIndex = index torrentSummary.mostRecentFileIndex = index
// update state // update state
state.playing.infoHash = infoHash state.playing.infoHash = infoHash
state.playing.fileIndex = index state.playing.fileIndex = index
state.playing.fileName = fileSummary.name state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video'
state.playing.type = TorrentPlayer.isVideo(fileSummary) : TorrentPlayer.isAudio(fileSummary) ? 'audio'
? 'video' : 'other'
: TorrentPlayer.isAudio(fileSummary)
? 'audio' // pick up where we left off
: 'other'
// pick up where we left off
let jumpToTime = 0 let jumpToTime = 0
if (resume && fileSummary.currentTime) { if (resume && fileSummary.currentTime) {
const fraction = fileSummary.currentTime / fileSummary.duration const fraction = fileSummary.currentTime / fileSummary.duration
@@ -277,24 +270,30 @@ export default class PlaybackController {
} }
} }
state.playing.jumpToTime = jumpToTime state.playing.jumpToTime = jumpToTime
// if it's audio, parse out the metadata (artist, title, etc) // if it's audio, parse out the metadata (artist, title, etc)
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, getAudioMetadata) ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, getAudioMetadata)
} else { } else {
getAudioMetadata() getAudioMetadata()
} }
function getAudioMetadata () { function getAudioMetadata () {
if (state.playing.type === 'audio') { if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
} }
} }
// if it's video, check for subtitles files that are done downloading // if it's video, check for subtitles files that are done downloading
dispatch('checkForSubtitles') dispatch('checkForSubtitles')
// enable previously selected subtitle track // enable previously selected subtitle track
if (fileSummary.selectedSubtitle) { if (fileSummary.selectedSubtitle) {
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true) dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
} }
state.window.title = fileSummary.name state.window.title = fileSummary.name
// play in VLC if set as default player (Preferences / Playback / Play in VLC) // play in VLC if set as default player (Preferences / Playback / Play in VLC)
if (this.state.saved.prefs.openExternalPlayer) { if (this.state.saved.prefs.openExternalPlayer) {
dispatch('openExternalPlayer') dispatch('openExternalPlayer')
@@ -302,8 +301,10 @@ export default class PlaybackController {
cb() cb()
return return
} }
// otherwise, play the video // otherwise, play the video
this.update() this.update()
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state)) ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
cb() cb()
} }
@@ -334,13 +335,28 @@ export default class PlaybackController {
dispatch('toggleFullScreen', false) dispatch('toggleFullScreen', false)
} }
restoreBounds(state) restoreBounds(state)
// Tell the WebTorrent process to kill the torrent-to-HTTP server // Tell the WebTorrent process to kill the torrent-to-HTTP server
ipcRenderer.send('wt-stop-server') ipcRenderer.send('wt-stop-server')
ipcRenderer.send('onPlayerClose') ipcRenderer.send('onPlayerClose')
// Playback Priority: resume previously paused downloads.
if (this.state.saved.prefs.highestPlaybackPriority) {
dispatch('resumePausedTorrents')
}
this.update() this.update()
} }
} }
// Checks whether we are connected and already casting
// Returns false if we not casting (state.playing.location === 'local')
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
function isCasting (state) {
return state.playing.location === 'chromecast' ||
state.playing.location === 'airplay' ||
state.playing.location === 'dlna'
}
function restoreBounds (state) {
ipcRenderer.send('setAspectRatio', 0)
if (state.window.bounds) {
ipcRenderer.send('setBounds', state.window.bounds, false)
}
}

View File

@@ -1,7 +1,8 @@
import { dispatch } from '../lib/dispatcher.js' const {dispatch} = require('../lib/dispatcher')
import { ipcRenderer } from 'electron' const ipcRenderer = require('electron').ipcRenderer
export default class PrefsController { // Controls the Preferences screen
module.exports = class PrefsController {
constructor (state, config) { constructor (state, config) {
this.state = state this.state = state
this.config = config this.config = config
@@ -12,25 +13,48 @@ export default class PrefsController {
const state = this.state const state = this.state
state.location.go({ state.location.go({
url: 'preferences', url: 'preferences',
setup (cb) { setup: function (cb) {
// initialize preferences // initialize preferences
state.window.title = 'Preferences' state.window.title = 'Preferences'
state.unsaved = Object.assign(state.unsaved || {}, {
prefs: Object.assign({}, state.saved.prefs)
})
ipcRenderer.send('setAllowNav', false) ipcRenderer.send('setAllowNav', false)
cb() cb()
}, },
destroy: () => { destroy: () => {
ipcRenderer.send('setAllowNav', true) ipcRenderer.send('setAllowNav', true)
this.save()
} }
}) })
} }
// Updates a single property in the saved prefs // Updates a single property in the UNSAVED prefs
// For example: updatePreferences('isFileHandler', true) // For example: updatePreferences('foo.bar', 'baz')
// Call save() to save to config.json
update (property, value) { update (property, value) {
if (property === 'isFileHandler') ipcRenderer.send('setDefaultFileHandler', value) const path = property.split('.')
else if (property === 'startup') ipcRenderer.send('setStartup', value) 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
}
this.state.saved.prefs[property] = value // All unsaved prefs take effect atomically, and are saved to config.json
save () {
const state = this.state
if (state.unsaved.prefs.isFileHandler !== state.saved.prefs.isFileHandler) {
ipcRenderer.send('setDefaultFileHandler', state.unsaved.prefs.isFileHandler)
}
if (state.unsaved.prefs.startup !== state.saved.prefs.startup) {
ipcRenderer.send('setStartup', state.unsaved.prefs.startup)
}
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
dispatch('stateSaveImmediate') dispatch('stateSaveImmediate')
dispatch('checkDownloadPath') dispatch('checkDownloadPath')
} }

View File

@@ -1,22 +1,26 @@
import remote from '@electron/remote' const electron = require('electron')
import fs from 'fs' const fs = require('fs')
import path from 'path' const path = require('path')
import parallel from 'run-parallel' const parallel = require('run-parallel')
import { dispatch } from '../lib/dispatcher.js'
export default class SubtitlesController { const remote = electron.remote
const {dispatch} = require('../lib/dispatcher')
module.exports = class SubtitlesController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
openSubtitles () { openSubtitles () {
const filenames = remote.dialog.showOpenDialogSync({ remote.dialog.showOpenDialog({
title: 'Select a subtitles file.', title: 'Select a subtitles file.',
filters: [{ name: 'Subtitles', extensions: ['vtt', 'srt'] }], filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
properties: ['openFile'] properties: [ 'openFile' ]
}, (filenames) => {
if (!Array.isArray(filenames)) return
this.addSubtitles(filenames, true)
}) })
if (!Array.isArray(filenames)) return
this.addSubtitles(filenames, true)
} }
selectSubtitle (ix) { selectSubtitle (ix) {
@@ -36,11 +40,12 @@ export default class SubtitlesController {
// Read the files concurrently, then add all resulting subtitle tracks // Read the files concurrently, then add all resulting subtitle tracks
const tasks = files.map((file) => (cb) => loadSubtitle(file, cb)) const tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
parallel(tasks, (err, tracks) => { parallel(tasks, function (err, tracks) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
// No dupes allowed for (let i = 0; i < tracks.length; i++) {
tracks.forEach((track, i) => { // No dupes allowed
const track = tracks[i]
let trackIndex = subtitles.tracks.findIndex((t) => let trackIndex = subtitles.tracks.findIndex((t) =>
track.filePath === t.filePath) track.filePath === t.filePath)
@@ -53,7 +58,7 @@ export default class SubtitlesController {
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) { if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
subtitles.selectedIndex = trackIndex subtitles.selectedIndex = trackIndex
} }
}) }
// Finally, make sure no two tracks have the same label // Finally, make sure no two tracks have the same label
relabelSubtitles(subtitles) relabelSubtitles(subtitles)
@@ -81,18 +86,18 @@ export default class SubtitlesController {
} }
} }
async function loadSubtitle (file, cb) { function loadSubtitle (file, cb) {
// Lazy load to keep startup fast // Lazy load to keep startup fast
const concat = await import('simple-concat') const concat = require('simple-concat')
const LanguageDetect = await import('languagedetect') const LanguageDetect = require('languagedetect')
const srtToVtt = await import('srt-to-vtt') const srtToVtt = require('srt-to-vtt')
// Read the .SRT or .VTT file, parse it, add subtitle track // Read the .SRT or .VTT file, parse it, add subtitle track
const filePath = file.path || file const filePath = file.path || file
const vttStream = fs.createReadStream(filePath).pipe(srtToVtt()) const vttStream = fs.createReadStream(filePath).pipe(srtToVtt())
concat(vttStream, (err, buf) => { concat(vttStream, function (err, buf) {
if (err) return dispatch('error', 'Can\'t parse subtitles file.') if (err) return dispatch('error', 'Can\'t parse subtitles file.')
// Detect what language the subtitles are in // Detect what language the subtitles are in
@@ -105,7 +110,7 @@ async function loadSubtitle (file, cb) {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'), buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected, language: langDetected,
label: langDetected, label: langDetected,
filePath filePath: filePath
} }
cb(null, track) cb(null, track)
@@ -114,8 +119,8 @@ async function loadSubtitle (file, cb) {
// Checks whether a language name like 'English' or 'German' matches the system // Checks whether a language name like 'English' or 'German' matches the system
// language, aka the current locale // language, aka the current locale
async function isSystemLanguage (language) { function isSystemLanguage (language) {
const iso639 = await import('iso-639-1') const iso639 = require('iso-639-1')
const osLangISO = window.navigator.language.split('-')[0] // eg 'en' const osLangISO = window.navigator.language.split('-')[0] // eg 'en'
const langIso = iso639.getCode(language) // eg 'de' if language is 'German' const langIso = iso639.getCode(language) // eg 'de' if language is 'German'
return langIso === osLangISO return langIso === osLangISO
@@ -125,7 +130,7 @@ async function isSystemLanguage (language) {
// Labels each track by language, eg 'German', 'English', 'English 2', ... // Labels each track by language, eg 'German', 'English', 'English 2', ...
function relabelSubtitles (subtitles) { function relabelSubtitles (subtitles) {
const counts = {} const counts = {}
subtitles.tracks.forEach(track => { subtitles.tracks.forEach(function (track) {
const lang = track.language const lang = track.language
counts[lang] = (counts[lang] || 0) + 1 counts[lang] = (counts[lang] || 0) + 1
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang

View File

@@ -1,15 +1,16 @@
import path from 'path' const path = require('path')
import { ipcRenderer } from 'electron' const ipcRenderer = require('electron').ipcRenderer
import TorrentSummary from '../lib/torrent-summary.js'
import sound from '../lib/sound.js'
import { dispatch } from '../lib/dispatcher.js'
export default class TorrentController { const TorrentSummary = require('../lib/torrent-summary')
const sound = require('../lib/sound')
const {dispatch} = require('../lib/dispatcher')
module.exports = class TorrentController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
torrentParsed (torrentKey, infoHash, magnetURI) { torrentInfoHash (torrentKey, infoHash) {
let torrentSummary = this.getTorrentSummary(torrentKey) let torrentSummary = this.getTorrentSummary(torrentKey)
console.log('got infohash for %s torrent %s', console.log('got infohash for %s torrent %s',
torrentSummary ? 'existing' : 'new', torrentKey) torrentSummary ? 'existing' : 'new', torrentKey)
@@ -24,7 +25,7 @@ export default class TorrentController {
} }
torrentSummary = { torrentSummary = {
torrentKey, torrentKey: torrentKey,
status: 'new' status: 'new'
} }
torrents.unshift(torrentSummary) torrents.unshift(torrentSummary)
@@ -32,7 +33,6 @@ export default class TorrentController {
} }
torrentSummary.infoHash = infoHash torrentSummary.infoHash = infoHash
torrentSummary.magnetURI = magnetURI
dispatch('update') dispatch('update')
} }
@@ -62,6 +62,7 @@ export default class TorrentController {
torrentSummary.status = 'downloading' torrentSummary.status = 'downloading'
torrentSummary.name = torrentSummary.displayName || torrentInfo.name torrentSummary.name = torrentSummary.displayName || torrentInfo.name
torrentSummary.path = torrentInfo.path torrentSummary.path = torrentInfo.path
torrentSummary.magnetURI = torrentInfo.magnetURI
// TODO: make torrentInfo immutable, save separately as torrentSummary.info // TODO: make torrentInfo immutable, save separately as torrentSummary.info
// For now, check whether torrentSummary.files has already been set: // For now, check whether torrentSummary.files has already been set:
const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
@@ -176,7 +177,7 @@ function showDoneNotification (torrent) {
silent: true silent: true
}) })
notif.onclick = () => { notif.onclick = function () {
ipcRenderer.send('show') ipcRenderer.send('show')
} }

View File

@@ -1,18 +1,18 @@
import fs from 'fs' const fs = require('fs')
import path from 'path' const path = require('path')
import electron from 'electron' const electron = require('electron')
import remote from '@electron/remote'
import { dispatch } from '../lib/dispatcher.js' const {dispatch} = require('../lib/dispatcher')
import { TorrentKeyNotFoundError } from '../lib/errors.js' const {TorrentKeyNotFoundError} = require('../lib/errors')
import sound from '../lib/sound.js' const sound = require('../lib/sound')
import TorrentSummary from '../lib/torrent-summary.js' const TorrentSummary = require('../lib/torrent-summary')
const { ipcRenderer, clipboard } = electron const ipcRenderer = electron.ipcRenderer
const instantIoRegex = /^(https:\/\/)?instant\.io\/#/ const instantIoRegex = /^(https:\/\/)?instant\.io\/#/
export default class TorrentListController { // Controls the torrent list: creating, adding, deleting, & manipulating torrents
module.exports = class TorrentListController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
@@ -55,7 +55,7 @@ export default class TorrentListController {
if (files.length === 0 || typeof files[0] !== 'string') { if (files.length === 0 || typeof files[0] !== 'string') {
this.state.location.go({ this.state.location.go({
url: 'create-torrent', url: 'create-torrent',
files, files: files,
setup: (cb) => { setup: (cb) => {
this.state.window.title = 'Create New Torrent' this.state.window.title = 'Create New Torrent'
cb(null) cb(null)
@@ -95,7 +95,7 @@ export default class TorrentListController {
if (!fileOrFolder) return start() if (!fileOrFolder) return start()
// Existing torrent: check that the path is still there // Existing torrent: check that the path is still there
fs.stat(fileOrFolder, err => { fs.stat(fileOrFolder, function (err) {
if (err) { if (err) {
s.error = 'path-missing' s.error = 'path-missing'
dispatch('backToList') dispatch('backToList')
@@ -121,10 +121,11 @@ export default class TorrentListController {
torrentSummary.status = 'new' torrentSummary.status = 'new'
this.startTorrentingSummary(torrentSummary.torrentKey) this.startTorrentingSummary(torrentSummary.torrentKey)
sound.play('ENABLE') sound.play('ENABLE')
return } else {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
} }
this.pauseTorrent(torrentSummary, true)
} }
pauseAllTorrents () { pauseAllTorrents () {
@@ -148,39 +149,6 @@ export default class TorrentListController {
sound.play('ENABLE') sound.play('ENABLE')
} }
pauseTorrent (torrentSummary, playSound) {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
if (playSound) sound.play('DISABLE')
}
prioritizeTorrent (infoHash) {
this.state.saved.torrents
.filter(torrent => ['downloading', 'seeding'].includes(torrent.status)) // Active torrents only.
.forEach((torrent) => { // Pause all active torrents except the one that started playing.
if (infoHash === torrent.infoHash) return
// Pause torrent without playing sounds.
this.pauseTorrent(torrent, false)
this.state.saved.torrentsToResume.push(torrent.infoHash)
})
console.log('Playback Priority: paused torrents: ', this.state.saved.torrentsToResume)
}
resumePausedTorrents () {
console.log('Playback Priority: resuming paused torrents')
if (!this.state.saved.torrentsToResume || !this.state.saved.torrentsToResume.length) return
this.state.saved.torrentsToResume.forEach((infoHash) => {
this.toggleTorrent(infoHash)
})
// reset paused torrents
this.state.saved.torrentsToResume = []
}
toggleTorrentFile (infoHash, index) { toggleTorrentFile (infoHash, index) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
torrentSummary.selections[index] = !torrentSummary.selections[index] torrentSummary.selections[index] = !torrentSummary.selections[index]
@@ -199,43 +167,26 @@ export default class TorrentListController {
} }
} }
confirmDeleteAllTorrents (deleteData) {
this.state.modal = {
id: 'delete-all-torrents-modal',
deleteData
}
}
// TODO: use torrentKey, not infoHash // TODO: use torrentKey, not infoHash
deleteTorrent (infoHash, deleteData) { deleteTorrent (infoHash, deleteData) {
ipcRenderer.send('wt-stop-torrenting', infoHash)
const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash) const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) { if (index > -1) {
const summary = this.state.saved.torrents[index] const summary = this.state.saved.torrents[index]
deleteTorrentFile(summary, deleteData)
// 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 // remove torrent from saved list
this.state.saved.torrents.splice(index, 1) this.state.saved.torrents.splice(index, 1)
dispatch('stateSave') dispatch('stateSave')
// prevent user from going forward to a deleted torrent
this.state.location.clearForward('player')
sound.play('DELETE')
} else {
throw new TorrentKeyNotFoundError(infoHash)
} }
}
deleteAllTorrents (deleteData) {
// Go back to list before the current playing torrent is deleted
if (this.state.location.url() === 'player') {
dispatch('backToList')
}
this.state.saved.torrents.forEach((summary) => deleteTorrentFile(summary, deleteData))
this.state.saved.torrents = []
dispatch('stateSave')
// prevent user from going forward to a deleted torrent // prevent user from going forward to a deleted torrent
this.state.location.clearForward('player') this.state.location.clearForward('player')
@@ -252,88 +203,77 @@ export default class TorrentListController {
openTorrentContextMenu (infoHash) { openTorrentContextMenu (infoHash) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const menu = new remote.Menu() const menu = new electron.remote.Menu()
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Remove From List', label: 'Remove From List',
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, false) click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, false)
})) }))
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Remove Data File', label: 'Remove Data File',
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, true) click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, true)
})) }))
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
type: 'separator' type: 'separator'
})) }))
if (torrentSummary.files) { if (torrentSummary.files) {
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder', label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder',
click: () => showItemInFolder(torrentSummary) click: () => showItemInFolder(torrentSummary)
})) }))
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
type: 'separator' type: 'separator'
})) }))
} }
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Copy Magnet Link to Clipboard', label: 'Copy Magnet Link to Clipboard',
click: () => clipboard.writeText(torrentSummary.magnetURI) click: () => electron.clipboard.writeText(torrentSummary.magnetURI)
})) }))
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Copy Instant.io Link to Clipboard', label: 'Copy Instant.io Link to Clipboard',
click: () => clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`) click: () => electron.clipboard.writeText(`https://instant.io/#${torrentSummary.infoHash}`)
})) }))
menu.append(new remote.MenuItem({ menu.append(new electron.remote.MenuItem({
label: 'Save Torrent File As...', label: 'Save Torrent File As...',
click: () => dispatch('saveTorrentFileAs', torrentSummary.torrentKey), click: () => dispatch('saveTorrentFileAs', torrentSummary.torrentKey),
enabled: torrentSummary.torrentFileName != null enabled: torrentSummary.torrentFileName != null
})) }))
menu.append(new remote.MenuItem({ menu.popup(electron.remote.getCurrentWindow())
type: 'separator'
}))
const sortedByName = this.state.saved.prefs.sortByName
menu.append(new remote.MenuItem({
label: `${sortedByName ? '✓ ' : ''}Sort by Name`,
click: () => dispatch('updatePreferences', 'sortByName', !sortedByName)
}))
menu.popup({ window: remote.getCurrentWindow() })
} }
// Takes a torrentSummary or torrentKey // Takes a torrentSummary or torrentKey
// Shows a Save File dialog, then saves the .torrent file wherever the user requests // Shows a Save File dialog, then saves the .torrent file wherever the user requests
saveTorrentFileAs (torrentKey) { saveTorrentFileAs (torrentKey) {
const torrentSummary = TorrentSummary.getByKey(this.state, torrentKey) const torrentSummary = TorrentSummary.getByKey(this.state, torrentKey)
if (!torrentSummary) throw new TorrentKeyNotFoundError(torrentKey) if (!torrentSummary) throw new Error('Missing torrentKey: ' + torrentKey)
const downloadPath = this.state.saved.prefs.downloadPath const downloadPath = this.state.saved.prefs.downloadPath
const newFileName = path.parse(torrentSummary.name).name + '.torrent' const newFileName = path.parse(torrentSummary.name).name + '.torrent'
const win = remote.getCurrentWindow() const win = electron.remote.getCurrentWindow()
const opts = { const opts = {
title: 'Save Torrent File', title: 'Save Torrent File',
defaultPath: path.join(downloadPath, newFileName), defaultPath: path.join(downloadPath, newFileName),
filters: [ filters: [
{ name: 'Torrent Files', extensions: ['torrent'] }, { name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] } { name: 'All Files', extensions: ['*'] }
], ]
buttonLabel: 'Save'
} }
const savePath = remote.dialog.showSaveDialogSync(win, opts) electron.remote.dialog.showSaveDialog(win, opts, function (savePath) {
console.log('Saving torrent ' + torrentKey + ' to ' + savePath)
if (!savePath) return // They clicked Cancel if (!savePath) return // They clicked Cancel
console.log('Saving torrent ' + torrentKey + ' to ' + savePath) const torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
const torrentPath = TorrentSummary.getTorrentPath(torrentSummary) fs.readFile(torrentPath, function (err, torrentFile) {
fs.readFile(torrentPath, (err, torrentFile) => {
if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, err => {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, function (err) {
if (err) return dispatch('error', err)
})
}) })
}) })
} }
@@ -341,16 +281,16 @@ export default class TorrentListController {
// Recursively finds {name, path, size} for all files in a folder // Recursively finds {name, path, size} for all files in a folder
// Calls `cb` on success, calls `onError` on failure // Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (paths, cb_) { function findFilesRecursive (paths, cb) {
if (paths.length > 1) { if (paths.length > 1) {
let numComplete = 0 let numComplete = 0
const ret = [] let ret = []
paths.forEach(path => { paths.forEach(function (path) {
findFilesRecursive([path], fileObjs => { findFilesRecursive([path], function (fileObjs) {
ret.push(...fileObjs) ret.push(...fileObjs)
if (++numComplete === paths.length) { if (++numComplete === paths.length) {
ret.sort((a, b) => a.path < b.path ? -1 : Number(a.path > b.path)) ret.sort((a, b) => a.path < b.path ? -1 : a.path > b.path)
cb_(ret) cb(ret)
} }
}) })
}) })
@@ -358,13 +298,13 @@ function findFilesRecursive (paths, cb_) {
} }
const fileOrFolder = paths[0] const fileOrFolder = paths[0]
fs.stat(fileOrFolder, (err, stat) => { fs.stat(fileOrFolder, function (err, stat) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
// Files: return name, path, and size // Files: return name, path, and size
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
const filePath = fileOrFolder const filePath = fileOrFolder
return cb_([{ return cb([{
name: path.basename(filePath), name: path.basename(filePath),
path: filePath, path: filePath,
size: stat.size size: stat.size
@@ -373,17 +313,17 @@ function findFilesRecursive (paths, cb_) {
// Folders: recurse, make a list of all the files // Folders: recurse, make a list of all the files
const folderPath = fileOrFolder const folderPath = fileOrFolder
fs.readdir(folderPath, (err, fileNames) => { fs.readdir(folderPath, function (err, fileNames) {
if (err) return dispatch('error', err) if (err) return dispatch('error', err)
const paths = fileNames.map((fileName) => path.join(folderPath, fileName)) const paths = fileNames.map((fileName) => path.join(folderPath, fileName))
findFilesRecursive(paths, cb_) findFilesRecursive(paths, cb)
}) })
}) })
} }
function deleteFile (path) { function deleteFile (path) {
if (!path) return if (!path) return
fs.unlink(path, err => { fs.unlink(path, function (err) {
if (err) dispatch('error', err) if (err) dispatch('error', err)
}) })
} }
@@ -397,14 +337,3 @@ function moveItemToTrash (torrentSummary) {
function showItemInFolder (torrentSummary) { function showItemInFolder (torrentSummary) {
ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary)) ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary))
} }
function deleteTorrentFile (torrentSummary, deleteData) {
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
// remove torrent and poster file
deleteFile(TorrentSummary.getTorrentPath(torrentSummary))
deleteFile(TorrentSummary.getPosterPath(torrentSummary))
// optionally delete the torrent data
if (deleteData) moveItemToTrash(torrentSummary)
}

View File

@@ -1,6 +1,7 @@
import { dispatch } from '../lib/dispatcher.js' const {dispatch} = require('../lib/dispatcher')
export default class UpdateController { // Controls the UI checking for new versions of the app, prompting install
module.exports = class UpdateController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
@@ -12,7 +13,7 @@ export default class UpdateController {
console.log('new version skipped by user: v' + version) console.log('new version skipped by user: v' + version)
return return
} }
this.state.modal = { id: 'update-available-modal', version } this.state.modal = { id: 'update-available-modal', version: version }
} }
// Don't show the modal again until the next version // Don't show the modal again until the next version

View File

@@ -1,18 +1,38 @@
import http from 'http' // The Cast module talks to Airplay and Chromecast
import config from '../src/config.js' // * Modifies state when things change
import { CastingError } from './errors.js' // * Starts and stops casting, provides remote video controls
module.exports = {
init,
toggleMenu,
selectDevice,
stop,
play,
pause,
seek,
setVolume,
setRate
}
const config = require('../../config')
const {CastingError} = require('./errors')
// Lazy load these for a ~300ms improvement in startup time // Lazy load these for a ~300ms improvement in startup time
let airplayer, chromecasts, dlnacasts let airplayer, chromecasts, dlnacasts
// App state. Cast modifies state.playing and state.errors in response to events // App state. Cast modifies state.playing and state.errors in response to events
let state let state
// Callback to notify module users when state has changed // Callback to notify module users when state has changed
let update let update
// setInterval() for updating cast status // setInterval() for updating cast status
let statusInterval = null let statusInterval = null
// Start looking for cast devices on the local network // Start looking for cast devices on the local network
function init (appState, callback) { function init (appState, callback) {
state = appState state = appState
update = callback update = callback
// Don't actually cast during integration tests // Don't actually cast during integration tests
// (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.) // (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.)
if (config.IS_TEST) { if (config.IS_TEST) {
@@ -21,26 +41,32 @@ function init (appState, callback) {
state.devices.dlna = testPlayer('dlna') state.devices.dlna = testPlayer('dlna')
return return
} }
// Load modules, scan the network for devices // Load modules, scan the network for devices
airplayer = require('airplayer')() airplayer = require('airplayer')()
chromecasts = require('chromecasts')() chromecasts = require('chromecasts')()
dlnacasts = require('dlnacasts')() dlnacasts = require('dlnacasts')()
state.devices.chromecast = chromecastPlayer() state.devices.chromecast = chromecastPlayer()
state.devices.dlna = dlnaPlayer() state.devices.dlna = dlnaPlayer()
state.devices.airplay = airplayPlayer() state.devices.airplay = airplayPlayer()
// Listen for devices: Chromecast, DLNA and Airplay // Listen for devices: Chromecast, DLNA and Airplay
chromecasts.on('update', device => { chromecasts.on('update', function (device) {
// TODO: how do we tell if there are *no longer* any Chromecasts available? // TODO: how do we tell if there are *no longer* any Chromecasts available?
// From looking at the code, chromecasts.players only grows, never shrinks // From looking at the code, chromecasts.players only grows, never shrinks
state.devices.chromecast.addDevice(device) state.devices.chromecast.addDevice(device)
}) })
dlnacasts.on('update', device => {
dlnacasts.on('update', function (device) {
state.devices.dlna.addDevice(device) state.devices.dlna.addDevice(device)
}) })
airplayer.on('update', device => {
airplayer.on('update', function (device) {
state.devices.airplay.addDevice(device) state.devices.airplay.addDevice(device)
}) })
} }
// integration test player implementation // integration test player implementation
function testPlayer (type) { function testPlayer (type) {
return { return {
@@ -53,17 +79,20 @@ function testPlayer (type) {
seek, seek,
volume volume
} }
function getDevices () { function getDevices () {
return [{ name: type + '-1' }, { name: type + '-2' }] return [{name: type + '-1'}, {name: type + '-2'}]
} }
function open () { }
function play () { } function open () {}
function pause () { } function play () {}
function stop () { } function pause () {}
function status () { } function stop () {}
function seek () { } function status () {}
function volume () { } function seek () {}
function volume () {}
} }
// chromecast player implementation // chromecast player implementation
function chromecastPlayer () { function chromecastPlayer () {
const ret = { const ret = {
@@ -79,11 +108,13 @@ function chromecastPlayer () {
volume volume
} }
return ret return ret
function getDevices () { function getDevices () {
return chromecasts.players return chromecasts.players
} }
function addDevice (device) { function addDevice (device) {
device.on('error', err => { device.on('error', function (err) {
if (device !== ret.device) return if (device !== ret.device) return
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -92,76 +123,57 @@ function chromecastPlayer () {
}) })
update() update()
}) })
device.on('disconnect', () => { device.on('disconnect', function () {
if (device !== ret.device) return if (device !== ret.device) return
state.playing.location = 'local' state.playing.location = 'local'
update() update()
}) })
} }
function serveSubtitles (callback) {
const subtitles = state.playing.subtitles
const selectedSubtitle = subtitles.tracks[subtitles.selectedIndex]
if (!selectedSubtitle) {
callback()
} else {
ret.subServer = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/vtt;charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Transfer-Encoding': 'chunked'
})
res.end(Buffer.from(selectedSubtitle.buffer.substr(21), 'base64'))
}).listen(0, () => {
const port = ret.subServer.address().port
const subtitlesUrl = 'http://' + state.server.networkAddress + ':' + port + '/'
callback(subtitlesUrl)
})
}
}
function open () { function open () {
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
serveSubtitles(subtitlesUrl => { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, { type: 'video/mp4',
type: 'video/mp4', title: config.APP_NAME + ' - ' + torrentSummary.name
title: config.APP_NAME + ' - ' + torrentSummary.name, }, function (err) {
subtitles: subtitlesUrl ? [subtitlesUrl] : [], if (err) {
autoSubtitles: !!subtitlesUrl state.playing.location = 'local'
}, err => { state.errors.push({
if (err) { time: new Date().getTime(),
state.playing.location = 'local' message: 'Could not connect to Chromecast. ' + err.message
state.errors.push({ })
time: new Date().getTime(), } else {
message: 'Could not connect to Chromecast. ' + err.message state.playing.location = 'chromecast'
}) }
} else { update()
state.playing.location = 'chromecast'
}
update()
})
}) })
} }
function play (callback) { function play (callback) {
ret.device.play(null, null, callback) ret.device.play(null, null, callback)
} }
function pause (callback) { function pause (callback) {
ret.device.pause(callback) ret.device.pause(callback)
} }
function stop (callback) { function stop (callback) {
ret.device.stop(callback) ret.device.stop(callback)
if (ret.subServer) {
ret.subServer.close()
}
} }
function status () { function status () {
ret.device.status(handleStatus) ret.device.status(handleStatus)
} }
function seek (time, callback) { function seek (time, callback) {
ret.device.seek(time, callback) ret.device.seek(time, callback)
} }
function volume (volume, callback) { function volume (volume, callback) {
ret.device.volume(volume, callback) ret.device.volume(volume, callback)
} }
} }
// airplay player implementation // airplay player implementation
function airplayPlayer () { function airplayPlayer () {
const ret = { const ret = {
@@ -177,8 +189,9 @@ function airplayPlayer () {
volume volume
} }
return ret return ret
function addDevice (player) { function addDevice (player) {
player.on('event', event => { player.on('event', function (event) {
switch (event.state) { switch (event.state) {
case 'loading': case 'loading':
break break
@@ -194,11 +207,13 @@ function airplayPlayer () {
update() update()
}) })
} }
function getDevices () { function getDevices () {
return airplayer.players return airplayer.players
} }
function open () { function open () {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, (err, res) => { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, function (err, res) {
if (err) { if (err) {
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -211,17 +226,21 @@ function airplayPlayer () {
update() update()
}) })
} }
function play (callback) { function play (callback) {
ret.device.resume(callback) ret.device.resume(callback)
} }
function pause (callback) { function pause (callback) {
ret.device.pause(callback) ret.device.pause(callback)
} }
function stop (callback) { function stop (callback) {
ret.device.stop(callback) ret.device.stop(callback)
} }
function status () { function status () {
ret.device.playbackInfo((err, res, status) => { ret.device.playbackInfo(function (err, res, status) {
if (err) { if (err) {
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -235,15 +254,18 @@ function airplayPlayer () {
} }
}) })
} }
function seek (time, callback) { function seek (time, callback) {
ret.device.scrub(time, callback) ret.device.scrub(time, callback)
} }
function volume (volume, callback) { function volume (volume, callback) {
// AirPlay doesn't support volume // AirPlay doesn't support volume
// TODO: We should just disable the volume slider // TODO: We should just disable the volume slider
state.playing.volume = volume state.playing.volume = volume
} }
} }
// DLNA player implementation // DLNA player implementation
function dlnaPlayer (player) { function dlnaPlayer (player) {
const ret = { const ret = {
@@ -259,11 +281,13 @@ function dlnaPlayer (player) {
volume volume
} }
return ret return ret
function getDevices () { function getDevices () {
return dlnacasts.players return dlnacasts.players
} }
function addDevice (device) { function addDevice (device) {
device.on('error', err => { device.on('error', function (err) {
if (device !== ret.device) return if (device !== ret.device) return
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -272,19 +296,20 @@ function dlnaPlayer (player) {
}) })
update() update()
}) })
device.on('disconnect', () => { device.on('disconnect', function () {
if (device !== ret.device) return if (device !== ret.device) return
state.playing.location = 'local' state.playing.location = 'local'
update() update()
}) })
} }
function open () { function open () {
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash) const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, { ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
type: 'video/mp4', type: 'video/mp4',
title: config.APP_NAME + ' - ' + torrentSummary.name, title: config.APP_NAME + ' - ' + torrentSummary.name,
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0 seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0
}, err => { }, function (err) {
if (err) { if (err) {
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -297,45 +322,56 @@ function dlnaPlayer (player) {
update() update()
}) })
} }
function play (callback) { function play (callback) {
ret.device.play(null, null, callback) ret.device.play(null, null, callback)
} }
function pause (callback) { function pause (callback) {
ret.device.pause(callback) ret.device.pause(callback)
} }
function stop (callback) { function stop (callback) {
ret.device.stop(callback) ret.device.stop(callback)
} }
function status () { function status () {
ret.device.status(handleStatus) ret.device.status(handleStatus)
} }
function seek (time, callback) { function seek (time, callback) {
ret.device.seek(time, callback) ret.device.seek(time, callback)
} }
function volume (volume, callback) { function volume (volume, callback) {
ret.device.volume(volume, err => { ret.device.volume(volume, function (err) {
// quick volume update // quick volume update
state.playing.volume = volume state.playing.volume = volume
callback(err) callback(err)
}) })
} }
} }
function handleStatus (err, status) { function handleStatus (err, status) {
if (err || !status) { if (err || !status) {
return console.log('error getting %s status: %o', state.playing.location, err || 'missing response') return console.log('error getting %s status: %o',
state.playing.location,
err || 'missing response')
} }
state.playing.isPaused = status.playerState === 'PAUSED' state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.muted ? 0 : status.volume.level state.playing.volume = status.volume.muted ? 0 : status.volume.level
update() update()
} }
// Start polling cast device state, whenever we're connected // Start polling cast device state, whenever we're connected
function startStatusInterval () { function startStatusInterval () {
statusInterval = setInterval(() => { statusInterval = setInterval(function () {
const player = getPlayer() const player = getPlayer()
if (player) player.status() if (player) player.status()
}, 1000) }, 1000)
} }
/* /*
* Shows the device menu for a given cast type ('chromecast', 'airplay', etc) * Shows the device menu for a given cast type ('chromecast', 'airplay', etc)
* The menu lists eg. all Chromecasts detected; the user can click one to cast. * The menu lists eg. all Chromecasts detected; the user can click one to cast.
@@ -347,38 +383,48 @@ function toggleMenu (location) {
state.devices.castMenu = null state.devices.castMenu = null
return return
} }
// Never cast to two devices at the same time // Never cast to two devices at the same time
if (state.playing.location !== 'local') { if (state.playing.location !== 'local') {
throw new CastingError(`You can't connect to ${location} when already connected to another device`) throw new CastingError(
`You can't connect to ${location} when already connected to another device`
)
} }
// Find all cast devices of the given type // Find all cast devices of the given type
const player = getPlayer(location) const player = getPlayer(location)
const devices = player ? player.getDevices() : [] const devices = player ? player.getDevices() : []
if (devices.length === 0) { if (devices.length === 0) {
throw new CastingError(`No ${location} devices available`) throw new CastingError(`No ${location} devices available`)
} }
// Show a menu // Show a menu
state.devices.castMenu = { location, devices } state.devices.castMenu = {location, devices}
} }
function selectDevice (index) { function selectDevice (index) {
const { location, devices } = state.devices.castMenu const {location, devices} = state.devices.castMenu
// Start casting // Start casting
const player = getPlayer(location) const player = getPlayer(location)
player.device = devices[index] player.device = devices[index]
player.open() player.open()
// Poll the casting device's status every few seconds // Poll the casting device's status every few seconds
startStatusInterval() startStatusInterval()
// Show the Connecting... screen // Show the Connecting... screen
state.devices.castMenu = null state.devices.castMenu = null
state.playing.castName = devices[index].name state.playing.castName = devices[index].name
state.playing.location = location + '-pending' state.playing.location = location + '-pending'
update() update()
} }
// Stops casting, move video back to local screen // Stops casting, move video back to local screen
function stop () { function stop () {
const player = getPlayer() const player = getPlayer()
if (player) { if (player) {
player.stop(() => { player.stop(function () {
player.device = null player.device = null
stoppedCasting() stoppedCasting()
}) })
@@ -387,6 +433,7 @@ function stop () {
stoppedCasting() stoppedCasting()
} }
} }
function stoppedCasting () { function stoppedCasting () {
state.playing.location = 'local' state.playing.location = 'local'
state.playing.jumpToTime = Number.isFinite(state.playing.currentTime) state.playing.jumpToTime = Number.isFinite(state.playing.currentTime)
@@ -394,6 +441,7 @@ function stoppedCasting () {
: 0 : 0
update() update()
} }
function getPlayer (location) { function getPlayer (location) {
if (location) { if (location) {
return state.devices[location] return state.devices[location]
@@ -407,14 +455,17 @@ function getPlayer (location) {
return null return null
} }
} }
function play () { function play () {
const player = getPlayer() const player = getPlayer()
if (player) player.play(castCallback) if (player) player.play(castCallback)
} }
function pause () { function pause () {
const player = getPlayer() const player = getPlayer()
if (player) player.pause(castCallback) if (player) player.pause(castCallback)
} }
function setRate (rate) { function setRate (rate) {
let player let player
let result = true let result = true
@@ -430,34 +481,17 @@ function setRate (rate) {
} }
return result return result
} }
function seek (time) { function seek (time) {
const player = getPlayer() const player = getPlayer()
if (player) { player.seek(time, castCallback) } if (player) player.seek(time, castCallback)
} }
function setVolume (volume) { function setVolume (volume) {
const player = getPlayer() const player = getPlayer()
if (player) { player.volume(volume, castCallback) } if (player) player.volume(volume, castCallback)
} }
function castCallback (...args) {
console.log('%s callback: %o', state.playing.location, args) function castCallback () {
} console.log('%s callback: %o', state.playing.location, arguments)
export { init }
export { toggleMenu }
export { selectDevice }
export { stop }
export { play }
export { pause }
export { seek }
export { setVolume }
export { setRate }
export default {
init,
toggleMenu,
selectDevice,
stop,
play,
pause,
seek,
setVolume,
setRate
} }

View File

@@ -1,11 +1,20 @@
module.exports = {
dispatch,
dispatcher,
setDispatch
}
const dispatchers = {} const dispatchers = {}
let _dispatch = () => { } let _dispatch = function () {}
function setDispatch (dispatch) { function setDispatch (dispatch) {
_dispatch = dispatch _dispatch = dispatch
} }
function dispatch (...args) { function dispatch (...args) {
_dispatch(...args) _dispatch(...args)
} }
// Most DOM event handlers are trivial functions like `() => dispatch(<args>)`. // Most DOM event handlers are trivial functions like `() => dispatch(<args>)`.
// For these, `dispatcher(<args>)` is preferred because it memoizes the handler // For these, `dispatcher(<args>)` is preferred because it memoizes the handler
// function. This prevents React from updating the listener functions on // function. This prevents React from updating the listener functions on
@@ -14,23 +23,17 @@ function dispatcher (...args) {
const str = JSON.stringify(args) const str = JSON.stringify(args)
let handler = dispatchers[str] let handler = dispatchers[str]
if (!handler) { if (!handler) {
handler = dispatchers[str] = e => { handler = dispatchers[str] = function (e) {
// Do not propagate click to elements below the button // Do not propagate click to elements below the button
e.stopPropagation() e.stopPropagation()
if (e.currentTarget.classList.contains('disabled')) { if (e.currentTarget.classList.contains('disabled')) {
// Ignore clicks on disabled elements // Ignore clicks on disabled elements
return return
} }
dispatch(...args) dispatch(...args)
} }
} }
return handler return handler
} }
export { dispatch }
export { dispatcher }
export { setDispatch }
export default {
dispatch,
dispatcher,
setDispatch
}

View File

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

View File

@@ -1,13 +0,0 @@
const mediaExtensions = {
audio: [
'.aac', '.aif', '.aiff', '.asf', '.dff', '.dsf', '.flac', '.m2a',
'.m2a', '.m4a', '.mpc', '.m4b', '.mka', '.mp2', '.mp3', '.mpc', '.oga',
'.ogg', '.opus', '.spx', '.wma', '.wav', '.wv', '.wvp'
],
video: [
'.avi', '.mp4', '.m4v', '.webm', '.mov', '.mkv', '.mpg', '.mpeg',
'.ogv', '.webm', '.wmv', '.m2ts'
],
image: ['.gif', '.jpg', '.jpeg', '.png']
}
export default mediaExtensions

View File

@@ -1,8 +1,14 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import fs, { copyFileSync } from 'fs'
import path from 'path' module.exports = {
import semver from 'semver' run
import config from '../../config.js' }
const fs = require('fs')
const path = require('path')
const semver = require('semver')
const config = require('../../config')
// Change `state.saved` (which will be saved back to config.json on exit) as // Change `state.saved` (which will be saved back to config.json on exit) as
// needed, for example to deal with config.json format changes across versions // needed, for example to deal with config.json format changes across versions
@@ -22,28 +28,16 @@ function run (state) {
if (semver.lt(version, '0.14.0')) migrate_0_14_0(saved) if (semver.lt(version, '0.14.0')) migrate_0_14_0(saved)
if (semver.lt(version, '0.17.0')) migrate_0_17_0(saved) if (semver.lt(version, '0.17.0')) migrate_0_17_0(saved)
if (semver.lt(version, '0.17.2')) migrate_0_17_2(saved) if (semver.lt(version, '0.17.2')) migrate_0_17_2(saved)
if (semver.lt(version, '0.21.0')) migrate_0_21_0(saved)
if (semver.lt(version, '0.22.0')) migrate_0_22_0(saved)
if (semver.lt(version, config.APP_VERSION)) {
installHandlers(state.saved)
}
// Config is now on the new version // Config is now on the new version
state.saved.version = config.APP_VERSION state.saved.version = config.APP_VERSION
} }
// Whenever the app is updated, re-install default handlers if the user has
// enabled them.
async function installHandlers (saved) {
if (saved.prefs.isFileHandler) {
const { ipcRenderer } = await import('electron')
ipcRenderer.send('setDefaultFileHandler', true)
}
}
function migrate_0_7_0 (saved) { function migrate_0_7_0 (saved) {
saved.torrents.forEach(ts => { const cpFile = require('cp-file')
const path = require('path')
saved.torrents.forEach(function (ts) {
const infoHash = ts.infoHash const infoHash = ts.infoHash
// Replace torrentPath with torrentFileName // Replace torrentPath with torrentFileName
@@ -62,7 +56,7 @@ function migrate_0_7_0 (saved) {
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent') dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
// Synchronous FS calls aren't ideal, but probably OK in a migration // Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once // that only runs once
if (src !== dst) copyFileSync(src, dst) if (src !== dst) cpFile.sync(src, dst)
delete ts.torrentPath delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent' ts.torrentFileName = infoHash + '.torrent'
@@ -77,13 +71,14 @@ function migrate_0_7_0 (saved) {
dst = path.join(config.POSTER_PATH, infoHash + extension) dst = path.join(config.POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration // Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once // that only runs once
if (src !== dst) copyFileSync(src, dst) if (src !== dst) cpFile.sync(src, dst)
delete ts.posterURL delete ts.posterURL
ts.posterFileName = infoHash + extension ts.posterFileName = infoHash + extension
} }
// Fix exception caused by incorrect file ordering. // Fix exception caused by incorrect file ordering.
// https://github.com/webtorrent/webtorrent-desktop/pull/604#issuecomment-222805214 // https://github.com/feross/webtorrent-desktop/pull/604#issuecomment-222805214
delete ts.defaultPlayFileIndex delete ts.defaultPlayFileIndex
delete ts.files delete ts.files
delete ts.selections delete ts.selections
@@ -106,8 +101,8 @@ function migrate_0_11_0 (saved) {
} }
} }
async function migrate_0_12_0 (saved) { function migrate_0_12_0 (saved) {
const TorrentSummary = await import('./torrent-summary') const TorrentSummary = require('./torrent-summary')
if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) { if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) {
saved.prefs.openExternalPlayer = saved.prefs.playInVlc saved.prefs.openExternalPlayer = saved.prefs.playInVlc
@@ -116,7 +111,7 @@ async function migrate_0_12_0 (saved) {
// Undo a terrible bug where clicking Play on a default torrent on a fresh // Undo a terrible bug where clicking Play on a default torrent on a fresh
// install results in a "path missing" error // install results in a "path missing" error
// See https://github.com/webtorrent/webtorrent-desktop/pull/806 // See https://github.com/feross/webtorrent-desktop/pull/806
const defaultTorrentFiles = [ const defaultTorrentFiles = [
'6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent', '6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent',
'88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent', '88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent',
@@ -124,7 +119,7 @@ async function migrate_0_12_0 (saved) {
'02767050e0be2fd4db9a2ad6c12416ac806ed6ed.torrent', '02767050e0be2fd4db9a2ad6c12416ac806ed6ed.torrent',
'3ba219a8634bf7bae3d848192b2da75ae995589d.torrent' '3ba219a8634bf7bae3d848192b2da75ae995589d.torrent'
] ]
saved.torrents.forEach(torrentSummary => { saved.torrents.forEach(function (torrentSummary) {
if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return
const fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary) const fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary)
if (!fileOrFolder) return if (!fileOrFolder) return
@@ -138,16 +133,16 @@ async function migrate_0_12_0 (saved) {
} }
function migrate_0_14_0 (saved) { function migrate_0_14_0 (saved) {
saved.torrents.forEach(ts => { saved.torrents.forEach(function (ts) {
delete ts.defaultPlayFileIndex delete ts.defaultPlayFileIndex
}) })
} }
function migrate_0_17_0 (saved) { function migrate_0_17_0 (saved) {
// Fix a sad, sad bug that resulted in 100MB+ config.json files // Fix a sad, sad bug that resulted in 100MB+ config.json files
saved.torrents.forEach(ts => { saved.torrents.forEach(function (ts) {
if (!ts.files) return if (!ts.files) return
ts.files.forEach(file => { ts.files.forEach(function (file) {
if (!file.audioInfo || !file.audioInfo.picture) return if (!file.audioInfo || !file.audioInfo.picture) return
// This contained a Buffer, which 30x'd in size when serialized to JSON // This contained a Buffer, which 30x'd in size when serialized to JSON
delete file.audioInfo.picture delete file.audioInfo.picture
@@ -158,14 +153,20 @@ function migrate_0_17_0 (saved) {
function migrate_0_17_2 (saved) { function migrate_0_17_2 (saved) {
// Remove the trailing dot (.) from the Wired CD torrent name, since // Remove the trailing dot (.) from the Wired CD torrent name, since
// folders/files that end in a trailing dot (.) or space are not deletable from // folders/files that end in a trailing dot (.) or space are not deletable from
// Windows Explorer. See: https://github.com/webtorrent/webtorrent-desktop/issues/905 // Windows Explorer. See: https://github.com/feross/webtorrent-desktop/issues/905
const cpFile = require('cp-file')
const rimraf = require('rimraf')
const OLD_NAME = 'The WIRED CD - Rip. Sample. Mash. Share.' const OLD_NAME = 'The WIRED CD - Rip. Sample. Mash. Share.'
const NEW_NAME = 'The WIRED CD - Rip. Sample. Mash. Share' const NEW_NAME = 'The WIRED CD - Rip. Sample. Mash. Share'
const OLD_HASH = '3ba219a8634bf7bae3d848192b2da75ae995589d' const OLD_HASH = '3ba219a8634bf7bae3d848192b2da75ae995589d'
const NEW_HASH = 'a88fda5954e89178c372716a6a78b8180ed4dad3' const NEW_HASH = 'a88fda5954e89178c372716a6a78b8180ed4dad3'
const ts = saved.torrents.find(ts => ts.infoHash === OLD_HASH) const ts = saved.torrents.find((ts) => {
return ts.infoHash === OLD_HASH
})
if (!ts) return // Wired CD torrent does not exist if (!ts) return // Wired CD torrent does not exist
@@ -188,8 +189,8 @@ function migrate_0_17_2 (saved) {
} catch (err) {} } catch (err) {}
ts.posterFileName = NEW_HASH + '.jpg' ts.posterFileName = NEW_HASH + '.jpg'
fs.rmdirSync(path.join(config.TORRENT_PATH, ts.torrentFileName)) rimraf.sync(path.join(config.TORRENT_PATH, ts.torrentFileName))
copyFileSync( cpFile.sync(
path.join(config.STATIC_PATH, 'wiredCd.torrent'), path.join(config.STATIC_PATH, 'wiredCd.torrent'),
path.join(config.TORRENT_PATH, NEW_HASH + '.torrent') path.join(config.TORRENT_PATH, NEW_HASH + '.torrent')
) )
@@ -205,20 +206,3 @@ function migrate_0_17_2 (saved) {
} catch (err) {} } catch (err) {}
} }
} }
function migrate_0_21_0 (saved) {
if (saved.prefs.soundNotifications == null) {
// The app used to always have sound notifications enabled
saved.prefs.soundNotifications = true
}
}
function migrate_0_22_0 (saved) {
if (saved.prefs.externalPlayerPath == null) {
saved.prefs.externalPlayerPath = ''
}
}
export default {
run
}

View File

@@ -1,36 +1,51 @@
import TorrentSummary from './torrent-summary.js' module.exports = {
import TorrentPlayer from './torrent-player.js' hasNext,
getNextIndex,
hasPrevious,
getPreviousIndex,
getCurrentLocalURL
}
const TorrentSummary = require('./torrent-summary')
const TorrentPlayer = require('./torrent-player')
const cache = { const cache = {
infoHash: null, infoHash: null,
previousIndex: null, previousIndex: null,
currentIndex: null, currentIndex: null,
nextIndex: null nextIndex: null
} }
function hasNext (state) { function hasNext (state) {
updateCache(state) updateCache(state)
return cache.nextIndex !== null return cache.nextIndex !== null
} }
function getNextIndex (state) { function getNextIndex (state) {
updateCache(state) updateCache(state)
return cache.nextIndex return cache.nextIndex
} }
function hasPrevious (state) { function hasPrevious (state) {
updateCache(state) updateCache(state)
return cache.previousIndex !== null return cache.previousIndex !== null
} }
function getPreviousIndex (state) { function getPreviousIndex (state) {
updateCache(state) updateCache(state)
return cache.previousIndex return cache.previousIndex
} }
function getCurrentLocalURL (state) { function getCurrentLocalURL (state) {
return state.server return state.server
? state.server.localURL + '/' + state.playing.fileIndex + '/' + ? state.server.localURL + '/' + state.playing.fileIndex
encodeURIComponent(state.playing.fileName)
: '' : ''
} }
function updateCache (state) { function updateCache (state) {
const infoHash = state.playing.infoHash const infoHash = state.playing.infoHash
const fileIndex = state.playing.fileIndex const fileIndex = state.playing.fileIndex
if (infoHash === cache.infoHash) { if (infoHash === cache.infoHash) {
switch (fileIndex) { switch (fileIndex) {
case cache.currentIndex: case cache.currentIndex:
@@ -49,10 +64,12 @@ function updateCache (state) {
} else { } else {
cache.infoHash = infoHash cache.infoHash = infoHash
} }
cache.previousIndex = findPreviousIndex(state) cache.previousIndex = findPreviousIndex(state)
cache.currentIndex = fileIndex cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state) cache.nextIndex = findNextIndex(state)
} }
function findPreviousIndex (state) { function findPreviousIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex - 1; i >= 0; i--) { for (let i = state.playing.fileIndex - 1; i >= 0; i--) {
@@ -60,6 +77,7 @@ function findPreviousIndex (state) {
} }
return null return null
} }
function findNextIndex (state) { function findNextIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex + 1; i < files.length; i++) { for (let i = state.playing.fileIndex + 1; i < files.length; i++) {
@@ -67,15 +85,3 @@ function findNextIndex (state) {
} }
return null return null
} }
export { hasNext }
export { getNextIndex }
export { hasPrevious }
export { getPreviousIndex }
export { getCurrentLocalURL }
export default {
hasNext,
getNextIndex,
hasPrevious,
getPreviousIndex,
getCurrentLocalURL
}

View File

@@ -1,11 +1,16 @@
import config from '../src/config.js' module.exports = {
import { InvalidSoundNameError } from './errors.js' play
import path from 'path' }
const config = require('../../config')
const {InvalidSoundNameError} = require('./errors')
const path = require('path')
const VOLUME = 0.25 const VOLUME = 0.25
// App state to access the soundNotifications preference
let state
/* Cache of Audio elements, for instant playback */ /* Cache of Audio elements, for instant playback */
const cache = {} const cache = {}
const sounds = { const sounds = {
ADD: { ADD: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
@@ -40,16 +45,8 @@ const sounds = {
volume: VOLUME volume: VOLUME
} }
} }
function init (appState) {
state = appState
}
function play (name) { function play (name) {
if (state == null) {
return
}
if (!state.saved.prefs.soundNotifications) {
return
}
let audio = cache[name] let audio = cache[name]
if (!audio) { if (!audio) {
const sound = sounds[name] const sound = sounds[name]
@@ -63,9 +60,3 @@ function play (name) {
audio.currentTime = 0 audio.currentTime = 0
audio.play() audio.play()
} }
export { init }
export { play }
export default {
init,
play
}

View File

@@ -1,33 +1,30 @@
import applicationConfig from 'application-config' const appConfig = require('application-config')('WebTorrent')
import path from 'path' const path = require('path')
import { EventEmitter } from 'events' const {EventEmitter} = require('events')
import config from '../../config.js' const config = require('../../config')
const appConfig = applicationConfig('WebTorrent')
const SAVE_DEBOUNCE_INTERVAL = 1000 const SAVE_DEBOUNCE_INTERVAL = 1000
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json') appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
const State = Object.assign(new EventEmitter(), { const State = module.exports = Object.assign(new EventEmitter(), {
getDefaultPlayState, getDefaultPlayState,
load, load,
// state.save() calls are rate-limited. Use state.saveImmediate() to skip limit. // state.save() calls are rate-limited. Use state.saveImmediate() to skip limit.
async save (...args) { save: function () {
// Perf optimization: Lazy-require debounce (and it's dependencies) // Perf optimization: Lazy-require debounce (and it's dependencies)
const debounce = await import('debounce') const debounce = require('debounce')
// After first State.save() invokation, future calls go straight to the // After first State.save() invokation, future calls go straight to the
// debounced function // debounced function
State.save = debounce(saveImmediate, SAVE_DEBOUNCE_INTERVAL) State.save = debounce(saveImmediate, SAVE_DEBOUNCE_INTERVAL)
State.save(...args) State.save(...arguments)
}, },
saveImmediate saveImmediate
}) })
export default State function getDefaultState () {
const LocationHistory = require('location-history')
async function getDefaultState () {
const { default: LocationHistory } = await import('location-history')
return { return {
/* /*
@@ -91,7 +88,6 @@ function getDefaultPlayState () {
return { return {
infoHash: null, /* the info hash of the torrent we're playing */ infoHash: null, /* the info hash of the torrent we're playing */
fileIndex: null, /* the zero-based index within the torrent */ fileIndex: null, /* the zero-based index within the torrent */
fileName: null, /* name of the file that is playing */
location: 'local', /* 'local', 'chromecast', 'airplay' */ location: 'local', /* 'local', 'chromecast', 'airplay' */
type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */ type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */
currentTime: 0, /* seconds */ currentTime: 0, /* seconds */
@@ -108,59 +104,55 @@ function getDefaultPlayState () {
selectedIndex: -1, /* current subtitle track */ selectedIndex: -1, /* current subtitle track */
showMenu: false /* popover menu, above the video */ showMenu: false /* popover menu, above the video */
}, },
audioTracks: {
tracks: [],
selectedIndex: 0, /* current audio track */
showMenu: false /* popover menu, above the video */
},
aspectRatio: 0 /* aspect ratio of the video */ aspectRatio: 0 /* aspect ratio of the video */
} }
} }
/* If the saved state file doesn't exist yet, here's what we use instead */ /* If the saved state file doesn't exist yet, here's what we use instead */
async function setupStateSaved () { function setupStateSaved (cb) {
const { copyFileSync, mkdirSync, readFileSync } = await import('fs') const cpFile = require('cp-file')
const { default: parseTorrent } = await import('parse-torrent') const fs = require('fs')
const parseTorrent = require('parse-torrent')
const parallel = require('run-parallel')
const saved = { const saved = {
prefs: { prefs: {
downloadPath: config.DEFAULT_DOWNLOAD_PATH, downloadPath: config.DEFAULT_DOWNLOAD_PATH,
isFileHandler: false, isFileHandler: false,
openExternalPlayer: false, openExternalPlayer: false,
externalPlayerPath: '', externalPlayerPath: null,
startup: false, startup: false
soundNotifications: true,
autoAddTorrents: false,
torrentsFolderPath: '',
highestPlaybackPriority: true
}, },
torrents: config.DEFAULT_TORRENTS.map(createTorrentObject), torrents: config.DEFAULT_TORRENTS.map(createTorrentObject),
torrentsToResume: [],
version: config.APP_VERSION /* make sure we can upgrade gracefully later */ version: config.APP_VERSION /* make sure we can upgrade gracefully later */
} }
// TODO: Doing several sync calls during first startup is not ideal const tasks = []
mkdirSync(config.POSTER_PATH, { recursive: true })
mkdirSync(config.TORRENT_PATH, { recursive: true })
config.DEFAULT_TORRENTS.forEach((t, i) => { config.DEFAULT_TORRENTS.map(function (t, i) {
const infoHash = saved.torrents[i].infoHash const infoHash = saved.torrents[i].infoHash
// TODO: Doing several sync calls during first startup is not ideal tasks.push(function (cb) {
copyFileSync( cpFile(
path.join(config.STATIC_PATH, t.posterFileName), path.join(config.STATIC_PATH, t.posterFileName),
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)) path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName))
) ).then(cb).catch(cb)
copyFileSync( })
path.join(config.STATIC_PATH, t.torrentFileName), tasks.push(function (cb) {
path.join(config.TORRENT_PATH, infoHash + '.torrent') cpFile(
) path.join(config.STATIC_PATH, t.torrentFileName),
path.join(config.TORRENT_PATH, infoHash + '.torrent')
).then(cb).catch(cb)
})
}) })
return saved parallel(tasks, function (err) {
if (err) return cb(err)
cb(null, saved)
})
function createTorrentObject (t) { function createTorrentObject (t) {
// TODO: Doing several sync calls during first startup is not ideal // TODO: Doing several fs.readFileSync calls during first startup is not ideal
const torrent = readFileSync(path.join(config.STATIC_PATH, t.torrentFileName)) const torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
const parsedTorrent = parseTorrent(torrent) const parsedTorrent = parseTorrent(torrent)
return { return {
@@ -205,25 +197,19 @@ function shouldHidePlayerControls () {
this.playing.playbackRate === 1 this.playing.playbackRate === 1
} }
async function load (cb) { function load (cb) {
console.log('state load') appConfig.read(function (err, saved) {
let saved = await appConfig.read() if (err || !saved.version) {
console.log('Missing config file: Creating new one')
if (!saved || !saved.version) { setupStateSaved(onSavedState)
console.log('Missing config file: Creating new one') } else {
try { onSavedState(null, saved)
saved = await setupStateSaved()
} catch (err) {
onSavedState(err)
return
} }
} })
onSavedState(null, saved) function onSavedState (err, saved) {
async function onSavedState (err, saved) {
if (err) return cb(err) if (err) return cb(err)
const state = await getDefaultState() const state = getDefaultState()
state.saved = saved state.saved = saved
if (process.type === 'renderer') { if (process.type === 'renderer') {
@@ -232,13 +218,12 @@ async function load (cb) {
migrations.run(state) migrations.run(state)
} }
console.log('calling cb')
cb(null, state) cb(null, state)
} }
} }
// Write state.saved to the JSON state file // Write state.saved to the JSON state file
async function saveImmediate (state, cb) { function saveImmediate (state, cb) {
console.log('Saving state to ' + appConfig.filePath) console.log('Saving state to ' + appConfig.filePath)
// Clean up, so that we're not saving any pending state // Clean up, so that we're not saving any pending state
@@ -247,9 +232,9 @@ async function saveImmediate (state, cb) {
// reading the torrent file or file(s) to seed & don't have an infohash // reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents copy.torrents = copy.torrents
.filter((x) => x.infoHash) .filter((x) => x.infoHash)
.map(x => { .map(function (x) {
const torrent = {} const torrent = {}
for (const key in x) { for (let key in x) {
if (key === 'progress' || key === 'torrentKey') { if (key === 'progress' || key === 'torrentKey') {
continue // Don't save progress info or key for the webtorrent process continue // Don't save progress info or key for the webtorrent process
} }
@@ -261,10 +246,8 @@ async function saveImmediate (state, cb) {
return torrent return torrent
}) })
try { appConfig.write(copy, (err) => {
await appConfig.write(copy) if (err) console.error(err)
State.emit('stateSaved') else State.emit('stateSaved')
} catch (err) { })
console.error(err)
}
} }

View File

@@ -1,14 +1,24 @@
import remote from '@electron/remote' // Collects anonymous usage stats and uncaught errors
import config from '../../config.js' // Reports back so that we can improve WebTorrent Desktop
module.exports = {
init,
send,
logUncaughtError,
logPlayAttempt
}
const electron = require('electron')
const config = require('../../config')
let telemetry let telemetry
async function init (state) { function init (state) {
telemetry = state.saved.telemetry telemetry = state.saved.telemetry
// First app run // First app run
if (!telemetry) { if (!telemetry) {
const crypto = await import('crypto') const crypto = require('crypto')
telemetry = state.saved.telemetry = { telemetry = state.saved.telemetry = {
userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID
} }
@@ -16,7 +26,7 @@ async function init (state) {
} }
} }
async function send (state) { function send (state) {
const now = new Date() const now = new Date()
telemetry.version = config.APP_VERSION telemetry.version = config.APP_VERSION
telemetry.timestamp = now.toISOString() telemetry.timestamp = now.toISOString()
@@ -32,7 +42,7 @@ async function send (state) {
return reset() return reset()
} }
const get = await import('simple-get') const get = require('simple-get')
const opts = { const opts = {
url: config.TELEMETRY_URL, url: config.TELEMETRY_URL,
@@ -40,7 +50,7 @@ async function send (state) {
json: true json: true
} }
get.post(opts, (err, res) => { get.post(opts, function (err, res) {
if (err) return console.error('Error sending telemetry', err) if (err) return console.error('Error sending telemetry', err)
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
return console.error(`Error sending telemetry, status code: ${res.statusCode}`) return console.error(`Error sending telemetry, status code: ${res.statusCode}`)
@@ -64,7 +74,7 @@ function reset () {
// Track screen resolution // Track screen resolution
function getScreenInfo () { function getScreenInfo () {
return remote.screen.getAllDisplays().map((screen) => ({ return electron.screen.getAllDisplays().map((screen) => ({
width: screen.size.width, width: screen.size.width,
height: screen.size.height, height: screen.size.height,
scaleFactor: screen.scaleFactor scaleFactor: screen.scaleFactor
@@ -72,8 +82,8 @@ function getScreenInfo () {
} }
// Track basic system info like OS version and amount of RAM // Track basic system info like OS version and amount of RAM
async function getSystemInfo () { function getSystemInfo () {
const os = await import('os') const os = require('os')
return { return {
osPlatform: process.platform, osPlatform: process.platform,
osRelease: os.type() + ' ' + os.release(), osRelease: os.type() + ' ' + os.release(),
@@ -88,7 +98,7 @@ async function getSystemInfo () {
function getTorrentStats (state) { function getTorrentStats (state) {
const count = state.saved.torrents.length const count = state.saved.torrents.length
let sizeMB = 0 let sizeMB = 0
const byStatus = { let byStatus = {
new: { count: 0, sizeMB: 0 }, new: { count: 0, sizeMB: 0 },
downloading: { count: 0, sizeMB: 0 }, downloading: { count: 0, sizeMB: 0 },
seeding: { count: 0, sizeMB: 0 }, seeding: { count: 0, sizeMB: 0 },
@@ -96,21 +106,22 @@ function getTorrentStats (state) {
} }
// First, count torrents & total file size // First, count torrents & total file size
for (const torrent of state.saved.torrents) { for (let i = 0; i < count; i++) {
const stat = byStatus[torrent.status] const t = state.saved.torrents[i]
if (!torrent || !torrent.files || !stat) continue const stat = byStatus[t.status]
if (!t || !t.files || !stat) continue
stat.count++ stat.count++
for (let j = 0; j < t.files.length; j++) {
for (const file of torrent.files) { const f = t.files[j]
if (!file || !file.length) continue if (!f || !f.length) continue
const fileSizeMB = file.length / (1 << 20) const fileSizeMB = f.length / (1 << 20)
sizeMB += fileSizeMB sizeMB += fileSizeMB
stat.sizeMB += fileSizeMB stat.sizeMB += fileSizeMB
} }
} }
// Then, round all the counts and sums to the nearest power of 2 // Then, round all the counts and sums to the nearest power of 2
const ret = roundTorrentStats({ count, sizeMB }) const ret = roundTorrentStats({count, sizeMB})
ret.byStatus = { ret.byStatus = {
new: roundTorrentStats(byStatus.new), new: roundTorrentStats(byStatus.new),
downloading: roundTorrentStats(byStatus.downloading), downloading: roundTorrentStats(byStatus.downloading),
@@ -134,7 +145,7 @@ function roundPow2 (n) {
if (n <= 0) return 0 if (n <= 0) return 0
// Otherwise, return 1, 2, 4, 8, etc by rounding in log space // Otherwise, return 1, 2, 4, 8, etc by rounding in log space
const log2 = Math.log(n) / Math.log(2) const log2 = Math.log(n) / Math.log(2)
return 2 ** Math.round(log2) return Math.pow(2, Math.round(log2))
} }
// An uncaught error happened in the main process or in one of the windows // An uncaught error happened in the main process or in one of the windows
@@ -187,7 +198,7 @@ function logUncaughtError (procName, e) {
// Log the app version *at the time of the error* // Log the app version *at the time of the error*
const version = config.APP_VERSION const version = config.APP_VERSION
telemetry.uncaughtErrors.push({ process: procName, message, stack, version }) telemetry.uncaughtErrors.push({process: procName, message, stack, version})
} }
// Turns a DOM element into a string, eg "DIV.my-class.visible" // Turns a DOM element into a string, eg "DIV.my-class.visible"
@@ -210,14 +221,3 @@ function logPlayAttempt (result) {
attempts.total = (attempts.total || 0) + 1 attempts.total = (attempts.total || 0) + 1
attempts[result] = (attempts[result] || 0) + 1 attempts[result] = (attempts[result] || 0) + 1
} }
export { init }
export { send }
export { logUncaughtError }
export { logPlayAttempt }
export default {
init,
send,
logUncaughtError,
logPlayAttempt
}

View File

@@ -1,17 +0,0 @@
function calculateEta (missing, 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 + ' min' : ''
const secondsStr = seconds + ' s'
return `${hoursStr} ${minutesStr} ${secondsStr} remaining`
}
export { calculateEta }
export default {
calculateEta
}

View File

@@ -1,49 +1,61 @@
import path from 'path' module.exports = {
import mediaExtensions from './media-extensions.js' isPlayable,
isVideo,
isAudio,
isTorrent,
isPlayableTorrentSummary
}
const path = require('path')
// Checks whether a fileSummary or file path is audio/video that we can play, // Checks whether a fileSummary or file path is audio/video that we can play,
// based on the file extension // based on the file extension
function isPlayable (file) { function isPlayable (file) {
return isVideo(file) || isAudio(file) return isVideo(file) || isAudio(file)
} }
// Checks whether a fileSummary or file path is playable video // Checks whether a fileSummary or file path is playable video
function isVideo (file) { function isVideo (file) {
return mediaExtensions.video.includes(getFileExtension(file)) return [
'.avi',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpg',
'.ogv',
'.webm',
'.wmv'
].includes(getFileExtension(file))
} }
// Checks whether a fileSummary or file path is playable audio // Checks whether a fileSummary or file path is playable audio
function isAudio (file) { function isAudio (file) {
return mediaExtensions.audio.includes(getFileExtension(file)) return [
'.aac',
'.ac3',
'.mp3',
'.ogg',
'.wav',
'.m4a'
].includes(getFileExtension(file))
} }
// Checks if the argument is either: // Checks if the argument is either:
// - a string that's a valid filename ending in .torrent // - a string that's a valid filename ending in .torrent
// - a file object where obj.name is ends in .torrent // - a file object where obj.name is ends in .torrent
// - a string that's a magnet link (magnet://...) // - a string that's a magnet link (magnet://...)
function isTorrent (file) { function isTorrent (file) {
return isTorrentFile(file) || isMagnetLink(file) const isTorrentFile = getFileExtension(file) === '.torrent'
} const isMagnet = typeof file === 'string' && /^(stream-)?magnet:/.test(file)
function isTorrentFile (file) { return isTorrentFile || isMagnet
return getFileExtension(file) === '.torrent'
}
function isMagnetLink (link) {
return typeof link === 'string' && /^(stream-)?magnet:/.test(link)
} }
function getFileExtension (file) { function getFileExtension (file) {
const name = typeof file === 'string' ? file : file.name const name = typeof file === 'string' ? file : file.name
return path.extname(name).toLowerCase() return path.extname(name).toLowerCase()
} }
function isPlayableTorrentSummary (torrentSummary) { function isPlayableTorrentSummary (torrentSummary) {
return torrentSummary.files && torrentSummary.files.some(isPlayable) return torrentSummary.files && torrentSummary.files.some(isPlayable)
} }
export { isPlayable }
export { isVideo }
export { isAudio }
export { isTorrent }
export { isMagnetLink }
export { isPlayableTorrentSummary }
export default {
isPlayable,
isVideo,
isAudio,
isTorrent,
isMagnetLink,
isPlayableTorrentSummary
}

View File

@@ -1,151 +1,83 @@
import captureFrame from 'capture-frame' module.exports = torrentPoster
import path from 'path'
import mediaExtensions from './media-extensions.js' const captureFrame = require('capture-frame')
const msgNoSuitablePoster = 'Cannot generate a poster from any files in the torrent' const path = require('path')
function torrentPoster (torrent, cb) { function torrentPoster (torrent, cb) {
// First, try to use a poster image if available // First, try to use a poster image if available
const posterFile = torrent.files.filter(file => /^poster\.(jpg|png|gif)$/.test(file.name))[0] const posterFile = torrent.files.filter(function (file) {
if (posterFile) return extractPoster(posterFile, cb) return /^poster\.(jpg|png|gif)$/.test(file.name)
// 'score' each media type based on total size present in torrent })[0]
const bestScore = ['audio', 'video', 'image'].map(mediaType => ({ if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb)
type: mediaType,
size: calculateDataLengthByExtension(torrent, mediaExtensions[mediaType]) // Second, try to use the largest video file
})).sort((a, b) => b.size - a.size)[0] // sort descending on size // Filter out file formats that the <video> tag definitely can't play
if (bestScore.size === 0) { const videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv'])
// Admit defeat, no video, audio or image had a significant presence if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb)
return cb(new Error(msgNoSuitablePoster))
} // Third, try to use the largest image file
// Based on which media type is dominant we select the corresponding poster function const imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.jpeg', '.png'])
switch (bestScore.type) { if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb)
case 'audio':
return torrentPosterFromAudio(torrent, cb) // TODO: generate a waveform from the largest sound file
case 'image': // Finally, admit defeat
return torrentPosterFromImage(torrent, cb) return cb(new Error('Cannot generate a poster from any files in the torrent'))
case 'video':
return torrentPosterFromVideo(torrent, cb)
}
} }
/**
* Calculate the total data size of file matching one of the provided extensions
* @param torrent
* @param extensions List of extension to match
* @returns {number} total size, of matches found (>= 0)
*/
function calculateDataLengthByExtension (torrent, extensions) {
const files = filterOnExtension(torrent, extensions)
if (files.length === 0) return 0
return files
.map(file => file.length)
.reduce((a, b) => a + b)
}
/**
* Get the largest file of a given torrent, filtered by provided extension
* @param torrent Torrent to search in
* @param extensions Extension whitelist filter
* @returns Torrent file object
*/
function getLargestFileByExtension (torrent, extensions) { function getLargestFileByExtension (torrent, extensions) {
const files = filterOnExtension(torrent, extensions) const files = torrent.files.filter(function (file) {
if (files.length === 0) return undefined
return files.reduce((a, b) => a.length > b.length ? a : b)
}
/**
* Filter file on a list extension, can be used to find al image files
* @param torrent Torrent to filter files from
* @param extensions File extensions to filter on
* @returns {Array} Array of torrent file objects matching one of the given extensions
*/
function filterOnExtension (torrent, extensions) {
return torrent.files.filter(file => {
const extname = path.extname(file.name).toLowerCase() const extname = path.extname(file.name).toLowerCase()
return extensions.indexOf(extname) !== -1 return extensions.indexOf(extname) !== -1
}) })
} if (files.length === 0) return undefined
/** return files.reduce(function (a, b) {
* Returns a score how likely the file is suitable as a poster return a.length > b.length ? a : b
* @param imgFile File object of an image
* @returns {number} Score, higher score is a better match
*/
function scoreAudioCoverFile (imgFile) {
const fileName = path.basename(imgFile.name, path.extname(imgFile.name)).toLowerCase()
const relevanceScore = {
cover: 80,
folder: 80,
album: 80,
front: 80,
back: 20,
spectrogram: -80
}
for (const keyword in relevanceScore) {
if (fileName === keyword) {
return relevanceScore[keyword]
}
if (fileName.indexOf(keyword) !== -1) {
return relevanceScore[keyword]
}
}
return 0
}
function torrentPosterFromAudio (torrent, cb) {
const imageFiles = filterOnExtension(torrent, mediaExtensions.image)
if (imageFiles.length === 0) return cb(new Error(msgNoSuitablePoster))
const bestCover = imageFiles.map(file => ({
file,
score: scoreAudioCoverFile(file)
})).reduce((a, b) => {
if (a.score > b.score) {
return a
}
if (b.score > a.score) {
return b
}
// If score is equal, pick the largest file, aiming for highest resolution
if (a.file.length > b.file.length) {
return a
}
return b
}) })
const extname = path.extname(bestCover.file.name)
bestCover.file.getBuffer((err, buf) => cb(err, buf, extname))
} }
function torrentPosterFromVideo (torrent, cb) {
const file = getLargestFileByExtension(torrent, mediaExtensions.video) function torrentPosterFromVideo (file, torrent, cb) {
const index = torrent.files.indexOf(file) const index = torrent.files.indexOf(file)
const server = torrent.createServer(0) const server = torrent.createServer(0)
server.listen(0, onListening) server.listen(0, onListening)
function onListening () { function onListening () {
const port = server.address().port const port = server.address().port
const url = 'http://localhost:' + port + '/' + index const url = 'http://localhost:' + port + '/' + index
const video = document.createElement('video') const video = document.createElement('video')
video.addEventListener('canplay', onCanPlay) video.addEventListener('canplay', onCanPlay)
video.volume = 0 video.volume = 0
video.src = url video.src = url
video.play() video.play()
function onCanPlay () { function onCanPlay () {
video.removeEventListener('canplay', onCanPlay) video.removeEventListener('canplay', onCanPlay)
video.addEventListener('seeked', onSeeked) video.addEventListener('seeked', onSeeked)
video.currentTime = Math.min((video.duration || 600) * 0.03, 60) video.currentTime = Math.min((video.duration || 600) * 0.03, 60)
} }
function onSeeked () { function onSeeked () {
video.removeEventListener('seeked', onSeeked) video.removeEventListener('seeked', onSeeked)
const frame = captureFrame(video)
const buf = frame && frame.image const buf = captureFrame(video)
// unload video element // unload video element
video.pause() video.pause()
video.src = '' video.src = ''
video.load() video.load()
server.destroy() server.destroy()
if (buf.length === 0) return cb(new Error(msgNoSuitablePoster))
if (buf.length === 0) return cb(new Error('Generated poster contains no data'))
cb(null, buf, '.jpg') cb(null, buf, '.jpg')
} }
} }
} }
function torrentPosterFromImage (torrent, cb) {
const file = getLargestFileByExtension(torrent, mediaExtensions.image) function torrentPosterFromImage (file, torrent, cb) {
extractPoster(file, cb)
}
function extractPoster (file, cb) {
const extname = path.extname(file.name) const extname = path.extname(file.name)
file.getBuffer((err, buf) => cb(err, buf, extname)) file.getBuffer((err, buf) => cb(err, buf, extname))
} }
export default torrentPoster

View File

@@ -1,11 +1,21 @@
import path from 'path' module.exports = {
import config from '../src/config.js' getPosterPath,
getTorrentPath,
getByKey,
getTorrentId,
getFileOrFolder
}
const path = require('path')
const config = require('../../config')
// Expects a torrentSummary // Expects a torrentSummary
// Returns an absolute path to the torrent file, or null if unavailable // Returns an absolute path to the torrent file, or null if unavailable
function getTorrentPath (torrentSummary) { function getTorrentPath (torrentSummary) {
if (!torrentSummary || !torrentSummary.torrentFileName) return null if (!torrentSummary || !torrentSummary.torrentFileName) return null
return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName) return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName)
} }
// Expects a torrentSummary // Expects a torrentSummary
// Returns an absolute path to the poster image, or null if unavailable // Returns an absolute path to the poster image, or null if unavailable
function getPosterPath (torrentSummary) { function getPosterPath (torrentSummary) {
@@ -15,6 +25,7 @@ function getPosterPath (torrentSummary) {
// Backslashes in URLS in CSS cause bizarre string encoding issues // Backslashes in URLS in CSS cause bizarre string encoding issues
return posterPath.replace(/\\/g, '/') return posterPath.replace(/\\/g, '/')
} }
// Expects a torrentSummary // Expects a torrentSummary
// Returns a torrentID: filename, magnet URI, or infohash // Returns a torrentID: filename, magnet URI, or infohash
function getTorrentId (torrentSummary) { function getTorrentId (torrentSummary) {
@@ -25,12 +36,15 @@ function getTorrentId (torrentSummary) {
return s.magnetURI || s.infoHash return s.magnetURI || s.infoHash
} }
} }
// Expects a torrentKey or infoHash // Expects a torrentKey or infoHash
// Returns the corresponding torrentSummary, or undefined // Returns the corresponding torrentSummary, or undefined
function getByKey (state, torrentKey) { function getByKey (state, torrentKey) {
if (!torrentKey) return undefined if (!torrentKey) return undefined
return state.saved.torrents.find((x) => x.torrentKey === torrentKey || x.infoHash === torrentKey) 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 // Returns the path to either the file (in a single-file torrent) or the root
// folder (in multi-file torrent) // folder (in multi-file torrent)
// WARNING: assumes that multi-file torrents consist of a SINGLE folder. // WARNING: assumes that multi-file torrents consist of a SINGLE folder.
@@ -42,15 +56,3 @@ function getFileOrFolder (torrentSummary) {
const dirname = ts.files[0].path.split(path.sep)[0] const dirname = ts.files[0].path.split(path.sep)[0]
return path.join(ts.path, dirname) return path.join(ts.path, dirname)
} }
export { getPosterPath }
export { getTorrentPath }
export { getByKey }
export { getTorrentId }
export { getFileOrFolder }
export default {
getPosterPath,
getTorrentPath,
getByKey,
getTorrentId,
getFileOrFolder
}

View File

@@ -1,43 +1,56 @@
/* eslint-disable import/first */ /**
* Perf optimization: Hook into require() to modify how certain modules load:
*
* - `inline-style-prefixer` (used by `material-ui`) takes ~40ms. It is not
* actually used because auto-prefixing is disabled with
* `darkBaseTheme.userAgent = false`. Return a fake object.
*/
let Module = require('module')
const _require = Module.prototype.require
Module.prototype.require = function (id) {
if (id === 'inline-style-prefixer') return {}
return _require.apply(this, arguments)
}
console.time('init') console.time('init')
// Perf optimization: Start asynchronously read on config file before all the const crashReporter = require('../crash-reporter')
// blocking import() calls below. crashReporter.init()
import State from './lib/state' // Perf optimization: Start asynchronously read on config file before all the
// blocking require() calls below.
const State = require('./lib/state')
State.load(onState) State.load(onState)
import createGetter from 'fn-getter' const createGetter = require('fn-getter')
import remote from '@electron/remote' const dragDrop = require('drag-drop')
import debounce from 'debounce' const electron = require('electron')
import dragDrop from 'drag-drop' const fs = require('fs')
import electron from 'electron' const React = require('react')
import fs from 'fs' const ReactDOM = require('react-dom')
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import config from '../config.js' const config = require('../config')
import telemetry from './lib/telemetry.js' const telemetry = require('./lib/telemetry')
import sound from './lib/sound.js' const sound = require('./lib/sound')
import TorrentPlayer from './lib/torrent-player.js' const TorrentPlayer = require('./lib/torrent-player')
import App from './pages/app.js'
import { setDispatch } from './lib/dispatcher.js'
import TorrentController from './controllers/torrent-controller.js' // Perf optimization: Needed immediately, so do not lazy load it below
import AudioTracksController from './controllers/audio-tracks-controller.js' const TorrentListController = require('./controllers/torrent-list-controller')
import SubtitlesController from './controllers/subtitles-controller.js'
import PrefsController from './controllers/prefs-controller.js'
import PlaybackController from './controllers/playback-controller.js'
import MediaController from './controllers/media-controller.js'
import TorrentListController from './controllers/torrent-list-controller.js'
import UpdateController from './controllers/update-controller.js'
import FolderWatcherController from './controllers/folder-watcher-controller.js'
const { clipboard, ipcRenderer } = electron // Required by Material UI -- adds `onTouchTap` event
require('react-tap-event-plugin')()
const App = require('./pages/app')
// Electron apps have two processes: a main process (node) runs first and starts
// a renderer process (essentially a Chrome window). We're in the renderer process,
// and this IPC channel receives from and sends messages to the main process
const ipcRenderer = electron.ipcRenderer
// Yo-yo pattern: state object lives here and percolates down thru all the views. // Yo-yo pattern: state object lives here and percolates down thru all the views.
// Events come back up from the views via dispatch(...) // Events come back up from the views via dispatch(...)
setDispatch(dispatch) require('./lib/dispatcher').setDispatch(dispatch)
// From dispatch(...), events are sent to one of the controllers // From dispatch(...), events are sent to one of the controllers
let controllers = null let controllers = null
@@ -63,7 +76,6 @@ function onState (err, _state) {
window.dispatch = dispatch window.dispatch = dispatch
telemetry.init(state) telemetry.init(state)
sound.init(state)
// Log uncaught JS errors // Log uncaught JS errors
window.addEventListener( window.addEventListener(
@@ -72,15 +84,33 @@ function onState (err, _state) {
// Create controllers // Create controllers
controllers = { controllers = {
media: createGetter(() => new MediaController(state)), media: createGetter(() => {
playback: createGetter(() => new PlaybackController(state, config, update)), const MediaController = require('./controllers/media-controller')
prefs: createGetter(() => new PrefsController(state, config)), return new MediaController(state)
subtitles: createGetter(() => new SubtitlesController(state)), }),
audioTracks: createGetter(() => new AudioTracksController(state)), playback: createGetter(() => {
torrent: createGetter(() => new TorrentController(state)), const PlaybackController = require('./controllers/playback-controller')
torrentList: createGetter(() => new TorrentListController(state)), return new PlaybackController(state, config, update)
update: createGetter(() => new UpdateController(state)), }),
folderWatcher: createGetter(() => new FolderWatcherController()) prefs: createGetter(() => {
const PrefsController = require('./controllers/prefs-controller')
return new PrefsController(state, config)
}),
subtitles: createGetter(() => {
const SubtitlesController = require('./controllers/subtitles-controller')
return new SubtitlesController(state)
}),
torrent: createGetter(() => {
const TorrentController = require('./controllers/torrent-controller')
return new TorrentController(state)
}),
torrentList: createGetter(() => {
return new TorrentListController(state)
}),
update: createGetter(() => {
const UpdateController = require('./controllers/update-controller')
return new UpdateController(state)
})
} }
// Add first page to location history // Add first page to location history
@@ -96,10 +126,7 @@ function onState (err, _state) {
resumeTorrents() resumeTorrents()
// Initialize ReactDOM // Initialize ReactDOM
ReactDOM.render( app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
<App state={state} ref={elem => { app = elem }} />,
document.querySelector('#body')
)
// Calling update() updates the UI given the current state // Calling update() updates the UI given the current state
// Do this at least once a second to give every file in every torrentSummary // Do this at least once a second to give every file in every torrentSummary
@@ -118,26 +145,11 @@ function onState (err, _state) {
// ...same thing if you paste a torrent // ...same thing if you paste a torrent
document.addEventListener('paste', onPaste) document.addEventListener('paste', onPaste)
// Add YouTube style hotkey shortcuts
window.addEventListener('keydown', onKeydown)
const debouncedFullscreenToggle = debounce(() => {
dispatch('toggleFullScreen')
}, 1000, true)
document.addEventListener('wheel', event => {
// ctrlKey detects pinch to zoom, http://crbug.com/289887
if (event.ctrlKey) {
event.preventDefault()
debouncedFullscreenToggle()
}
})
// ...focus and blur. Needed to show correct dock icon text ('badge') in OSX // ...focus and blur. Needed to show correct dock icon text ('badge') in OSX
window.addEventListener('focus', onFocus) window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur) window.addEventListener('blur', onBlur)
if (remote.getCurrentWindow().isVisible()) { if (electron.remote.getCurrentWindow().isVisible()) {
sound.play('STARTUP') sound.play('STARTUP')
} }
@@ -167,9 +179,9 @@ function delayedInit () {
} }
// Lazily loads Chromecast and Airplay support // Lazily loads Chromecast and Airplay support
async function lazyLoadCast () { function lazyLoadCast () {
if (!Cast) { if (!Cast) {
Cast = (await import('./lib/cast')).default Cast = require('./lib/cast')
Cast.init(state, update) // Search the local network for Chromecast and Airplays Cast.init(state, update) // Search the local network for Chromecast and Airplays
} }
return Cast return Cast
@@ -205,111 +217,96 @@ function updateElectron () {
const dispatchHandlers = { const dispatchHandlers = {
// Torrent list: creating, deleting, selecting torrents // Torrent list: creating, deleting, selecting torrents
openTorrentFile: () => ipcRenderer.send('openTorrentFile'), 'openTorrentFile': () => ipcRenderer.send('openTorrentFile'),
openFiles: () => ipcRenderer.send('openFiles'), /* shows the open file dialog */ 'openFiles': () => ipcRenderer.send('openFiles'), /* shows the open file dialog */
openTorrentAddress: () => { state.modal = { id: 'open-torrent-address-modal' } }, 'openTorrentAddress': () => { state.modal = { id: 'open-torrent-address-modal' } },
addTorrent: (torrentId) => controllers.torrentList().addTorrent(torrentId), 'addTorrent': (torrentId) => controllers.torrentList().addTorrent(torrentId),
showCreateTorrent: (paths) => controllers.torrentList().showCreateTorrent(paths), 'showCreateTorrent': (paths) => controllers.torrentList().showCreateTorrent(paths),
createTorrent: (options) => controllers.torrentList().createTorrent(options), 'createTorrent': (options) => controllers.torrentList().createTorrent(options),
toggleTorrent: (infoHash) => controllers.torrentList().toggleTorrent(infoHash), 'toggleTorrent': (infoHash) => controllers.torrentList().toggleTorrent(infoHash),
pauseAllTorrents: () => controllers.torrentList().pauseAllTorrents(), 'pauseAllTorrents': () => controllers.torrentList().pauseAllTorrents(),
resumeAllTorrents: () => controllers.torrentList().resumeAllTorrents(), 'resumeAllTorrents': () => controllers.torrentList().resumeAllTorrents(),
toggleTorrentFile: (infoHash, index) => 'toggleTorrentFile': (infoHash, index) =>
controllers.torrentList().toggleTorrentFile(infoHash, index), controllers.torrentList().toggleTorrentFile(infoHash, index),
confirmDeleteTorrent: (infoHash, deleteData) => 'confirmDeleteTorrent': (infoHash, deleteData) =>
controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData), controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData),
deleteTorrent: (infoHash, deleteData) => 'deleteTorrent': (infoHash, deleteData) =>
controllers.torrentList().deleteTorrent(infoHash, deleteData), controllers.torrentList().deleteTorrent(infoHash, deleteData),
openTorrentListContextMenu: () => onPaste(), 'toggleSelectTorrent': (infoHash) =>
confirmDeleteAllTorrents: (deleteData) =>
controllers.torrentList().confirmDeleteAllTorrents(deleteData),
deleteAllTorrents: (deleteData) =>
controllers.torrentList().deleteAllTorrents(deleteData),
toggleSelectTorrent: (infoHash) =>
controllers.torrentList().toggleSelectTorrent(infoHash), controllers.torrentList().toggleSelectTorrent(infoHash),
openTorrentContextMenu: (infoHash) => 'openTorrentContextMenu': (infoHash) =>
controllers.torrentList().openTorrentContextMenu(infoHash), controllers.torrentList().openTorrentContextMenu(infoHash),
startTorrentingSummary: (torrentKey) => 'startTorrentingSummary': (torrentKey) =>
controllers.torrentList().startTorrentingSummary(torrentKey), controllers.torrentList().startTorrentingSummary(torrentKey),
saveTorrentFileAs: (torrentKey) => 'saveTorrentFileAs': (torrentKey) =>
controllers.torrentList().saveTorrentFileAs(torrentKey), controllers.torrentList().saveTorrentFileAs(torrentKey),
prioritizeTorrent: (infoHash) => controllers.torrentList().prioritizeTorrent(infoHash),
resumePausedTorrents: () => controllers.torrentList().resumePausedTorrents(),
// Playback // Playback
playFile: (infoHash, index) => controllers.playback().playFile(infoHash, index), 'playFile': (infoHash, index) => controllers.playback().playFile(infoHash, index),
playPause: () => controllers.playback().playPause(), 'playPause': () => controllers.playback().playPause(),
nextTrack: () => controllers.playback().nextTrack(), 'nextTrack': () => controllers.playback().nextTrack(),
previousTrack: () => controllers.playback().previousTrack(), 'previousTrack': () => controllers.playback().previousTrack(),
skip: (time) => controllers.playback().skip(time), 'skip': (time) => controllers.playback().skip(time),
skipTo: (time) => controllers.playback().skipTo(time), 'skipTo': (time) => controllers.playback().skipTo(time),
preview: (x) => controllers.playback().preview(x), 'changePlaybackRate': (dir) => controllers.playback().changePlaybackRate(dir),
clearPreview: () => controllers.playback().clearPreview(), 'changeVolume': (delta) => controllers.playback().changeVolume(delta),
changePlaybackRate: (dir) => controllers.playback().changePlaybackRate(dir), 'setVolume': (vol) => controllers.playback().setVolume(vol),
changeVolume: (delta) => controllers.playback().changeVolume(delta), 'openItem': (infoHash, index) => controllers.playback().openItem(infoHash, index),
setVolume: (vol) => controllers.playback().setVolume(vol),
openPath: (infoHash, index) => controllers.playback().openPath(infoHash, index),
// Subtitles // Subtitles
openSubtitles: () => controllers.subtitles().openSubtitles(), 'openSubtitles': () => controllers.subtitles().openSubtitles(),
selectSubtitle: (index) => controllers.subtitles().selectSubtitle(index), 'selectSubtitle': (index) => controllers.subtitles().selectSubtitle(index),
toggleSubtitlesMenu: () => controllers.subtitles().toggleSubtitlesMenu(), 'toggleSubtitlesMenu': () => controllers.subtitles().toggleSubtitlesMenu(),
checkForSubtitles: () => controllers.subtitles().checkForSubtitles(), 'checkForSubtitles': () => controllers.subtitles().checkForSubtitles(),
addSubtitles: (files, autoSelect) => controllers.subtitles().addSubtitles(files, autoSelect), 'addSubtitles': (files, autoSelect) => controllers.subtitles().addSubtitles(files, autoSelect),
// Audio Tracks
selectAudioTrack: (index) => controllers.audioTracks().selectAudioTrack(index),
toggleAudioTracksMenu: () => controllers.audioTracks().toggleAudioTracksMenu(),
// Local media: <video>, <audio>, external players // Local media: <video>, <audio>, external players
mediaStalled: () => controllers.media().mediaStalled(), 'mediaStalled': () => controllers.media().mediaStalled(),
mediaError: (err) => controllers.media().mediaError(err), 'mediaError': (err) => controllers.media().mediaError(err),
mediaSuccess: () => controllers.media().mediaSuccess(), 'mediaSuccess': () => controllers.media().mediaSuccess(),
mediaTimeUpdate: () => controllers.media().mediaTimeUpdate(), 'mediaTimeUpdate': () => controllers.media().mediaTimeUpdate(),
mediaMouseMoved: () => controllers.media().mediaMouseMoved(), 'mediaMouseMoved': () => controllers.media().mediaMouseMoved(),
mediaControlsMouseEnter: () => controllers.media().controlsMouseEnter(), 'mediaControlsMouseEnter': () => controllers.media().controlsMouseEnter(),
mediaControlsMouseLeave: () => controllers.media().controlsMouseLeave(), 'mediaControlsMouseLeave': () => controllers.media().controlsMouseLeave(),
openExternalPlayer: () => controllers.media().openExternalPlayer(), 'openExternalPlayer': () => controllers.media().openExternalPlayer(),
externalPlayerNotFound: () => controllers.media().externalPlayerNotFound(), 'externalPlayerNotFound': () => controllers.media().externalPlayerNotFound(),
// Remote casting: Chromecast, Airplay, etc // Remote casting: Chromecast, Airplay, etc
toggleCastMenu: (deviceType) => lazyLoadCast().toggleMenu(deviceType), 'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType),
selectCastDevice: (index) => lazyLoadCast().selectDevice(index), 'selectCastDevice': (index) => lazyLoadCast().selectDevice(index),
stopCasting: () => lazyLoadCast().stop(), 'stopCasting': () => lazyLoadCast().stop(),
// Preferences screen // Preferences screen
preferences: () => controllers.prefs().show(), 'preferences': () => controllers.prefs().show(),
updatePreferences: (key, value) => controllers.prefs().update(key, value), 'updatePreferences': (key, value) => controllers.prefs().update(key, value),
checkDownloadPath, 'checkDownloadPath': checkDownloadPath,
startFolderWatcher: () => controllers.folderWatcher().start(),
stopFolderWatcher: () => controllers.folderWatcher().stop(),
// Update (check for new versions on Linux, where there's no auto updater) // Update (check for new versions on Linux, where there's no auto updater)
updateAvailable: (version) => controllers.update().updateAvailable(version), 'updateAvailable': (version) => controllers.update().updateAvailable(version),
skipVersion: (version) => controllers.update().skipVersion(version), 'skipVersion': (version) => controllers.update().skipVersion(version),
// Navigation between screens (back, forward, ESC, etc) // Navigation between screens (back, forward, ESC, etc)
exitModal: () => { state.modal = null }, 'exitModal': () => { state.modal = null },
backToList, 'backToList': backToList,
escapeBack, 'escapeBack': escapeBack,
back: () => state.location.back(), 'back': () => state.location.back(),
forward: () => state.location.forward(), 'forward': () => state.location.forward(),
cancel: () => state.location.cancel(), 'cancel': () => state.location.cancel(),
// Controlling the window // Controlling the window
setDimensions, 'setDimensions': setDimensions,
toggleFullScreen: (setTo) => ipcRenderer.send('toggleFullScreen', setTo), 'toggleFullScreen': (setTo) => ipcRenderer.send('toggleFullScreen', setTo),
setTitle: (title) => { state.window.title = title }, 'setTitle': (title) => { state.window.title = title },
resetTitle: () => { state.window.title = config.APP_WINDOW_TITLE }, 'resetTitle': () => { state.window.title = config.APP_WINDOW_TITLE },
// Everything else // Everything else
onOpen, 'onOpen': onOpen,
error: onError, 'error': onError,
uncaughtError: (proc, err) => telemetry.logUncaughtError(proc, err), 'uncaughtError': (proc, err) => telemetry.logUncaughtError(proc, err),
stateSave: () => State.save(state), 'stateSave': () => State.save(state),
stateSaveImmediate: () => State.saveImmediate(state), 'stateSaveImmediate': () => State.saveImmediate(state),
update: () => {} // No-op, just trigger an update 'update': () => {} // No-op, just trigger an update
} }
// Events from the UI never modify state directly. Instead they call dispatch() // Events from the UI never modify state directly. Instead they call dispatch()
@@ -341,10 +338,9 @@ function setupIpc () {
ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged) ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged)
const tc = controllers.torrent() const tc = controllers.torrent()
ipcRenderer.on('wt-parsed', (e, ...args) => tc.torrentParsed(...args)) ipcRenderer.on('wt-infohash', (e, ...args) => tc.torrentInfoHash(...args))
ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args)) ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...args))
ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args)) ipcRenderer.on('wt-done', (e, ...args) => tc.torrentDone(...args))
ipcRenderer.on('wt-done', () => controllers.torrentList().resumePausedTorrents())
ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args)) ipcRenderer.on('wt-warning', (e, ...args) => tc.torrentWarning(...args))
ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args)) ipcRenderer.on('wt-error', (e, ...args) => tc.torrentError(...args))
@@ -366,7 +362,7 @@ function setupIpc () {
function backToList () { function backToList () {
// Exit any modals and screens with a back button // Exit any modals and screens with a back button
state.modal = null state.modal = null
state.location.backToFirst(() => { state.location.backToFirst(function () {
// If we were already on the torrent list, scroll to the top // If we were already on the torrent list, scroll to the top
const contentTag = document.querySelector('.content') const contentTag = document.querySelector('.content')
if (contentTag) contentTag.scrollTop = 0 if (contentTag) contentTag.scrollTop = 0
@@ -400,7 +396,7 @@ function resumeTorrents () {
// Set window dimensions to match video dimensions or fill the screen // Set window dimensions to match video dimensions or fill the screen
function setDimensions (dimensions) { function setDimensions (dimensions) {
// Don't modify the window size if it's already maximized // Don't modify the window size if it's already maximized
if (remote.getCurrentWindow().isMaximized()) { if (electron.remote.getCurrentWindow().isMaximized()) {
state.window.bounds = null state.window.bounds = null
return return
} }
@@ -412,7 +408,7 @@ function setDimensions (dimensions) {
width: window.outerWidth, width: window.outerWidth,
height: window.outerHeight height: window.outerHeight
} }
state.window.wasMaximized = remote.getCurrentWindow().isMaximized state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
// Limit window size to screen size // Limit window size to screen size
const screenWidth = window.screen.width const screenWidth = window.screen.width
@@ -432,21 +428,14 @@ function setDimensions (dimensions) {
) )
ipcRenderer.send('setAspectRatio', aspectRatio) ipcRenderer.send('setAspectRatio', aspectRatio)
ipcRenderer.send('setBounds', { contentBounds: true, x: null, y: null, width, height }) ipcRenderer.send('setBounds', {contentBounds: true, x: null, y: null, width, height})
state.playing.aspectRatio = aspectRatio state.playing.aspectRatio = aspectRatio
} }
// Called when the user adds files (.torrent, files to seed, subtitles) to the app // 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) // via any method (drag-drop, drag to app icon, command line)
function onOpen (files) { function onOpen (files) {
if (!Array.isArray(files)) files = [files] if (!Array.isArray(files)) files = [ files ]
// File API seems to transform "magnet:?foo" in "magnet:///?foo"
// this is a sanitization
files = files.map(file => {
if (typeof file !== 'string') return file
return file.replace(/^magnet:\/+\?/i, 'magnet:?')
})
const url = state.location.url() const url = state.location.url()
const allTorrents = files.every(TorrentPlayer.isTorrent) const allTorrents = files.every(TorrentPlayer.isTorrent)
@@ -486,39 +475,8 @@ function onError (err) {
const editableHtmlTags = new Set(['input', 'textarea']) const editableHtmlTags = new Set(['input', 'textarea'])
function onPaste (e) { function onPaste (e) {
if (e && editableHtmlTags.has(e.target.tagName.toLowerCase())) return
controllers.torrentList().addTorrent(clipboard.readText())
update()
}
function onKeydown (e) {
// prevent event fire on user input elements
if (editableHtmlTags.has(e.target.tagName.toLowerCase())) return if (editableHtmlTags.has(e.target.tagName.toLowerCase())) return
controllers.torrentList().addTorrent(electron.clipboard.readText())
const key = e.key
if (key === 'ArrowLeft') {
dispatch('skip', -5)
} else if (key === 'ArrowRight') {
dispatch('skip', 5)
} else if (key === 'ArrowUp') {
dispatch('changeVolume', 0.1)
} else if (key === 'ArrowDown') {
dispatch('changeVolume', -0.1)
} else if (key === 'j') {
dispatch('skip', -10)
} else if (key === 'l') {
dispatch('skip', 10)
} else if (key === 'k') {
dispatch('playPause')
} else if (key === '>') {
dispatch('changePlaybackRate', 1)
} else if (key === '<') {
dispatch('changePlaybackRate', -1)
} else if (key === 'f') {
dispatch('toggleFullScreen')
}
update() update()
} }
@@ -535,7 +493,7 @@ function onBlur () {
} }
function onVisibilityChange () { function onVisibilityChange () {
state.window.isVisible = !document.hidden state.window.isVisible = !document.webkitHidden
} }
function onFullscreenChanged (e, isFullScreen) { function onFullscreenChanged (e, isFullScreen) {
@@ -556,7 +514,7 @@ function onWindowBoundsChanged (e, newBounds) {
} }
function checkDownloadPath () { function checkDownloadPath () {
fs.stat(state.saved.prefs.downloadPath, (err, stat) => { fs.stat(state.saved.prefs.downloadPath, function (err, stat) {
if (err) { if (err) {
state.downloadPathStatus = 'missing' state.downloadPathStatus = 'missing'
return console.error(err) return console.error(err)

View File

@@ -1,35 +1,30 @@
import * as colors from 'material-ui/styles/colors' const colors = require('material-ui/styles/colors')
import createGetter from 'fn-getter' const createGetter = require('fn-getter')
import * as React from 'react' const React = require('react')
import darkBaseTheme$0 from 'material-ui/styles/baseThemes/darkBaseTheme'
import getMuiTheme$0 from 'material-ui/styles/getMuiTheme' const darkBaseTheme = require('material-ui/styles/baseThemes/darkBaseTheme').default
import muiThemeProvider from 'material-ui/styles/MuiThemeProvider' const getMuiTheme = require('material-ui/styles/getMuiTheme').default
import Header from '../components/header.js' const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default
import TorrentListPage from './torrent-list-page.js'
import playerPage from './player-page.js' const Header = require('../components/header')
import createTorrentPage from './create-torrent-page.js'
import preferencesPage from './preferences-page.js' // Perf optimization: Needed immediately, so do not lazy load it below
import openTorrentAddressModal from '../components/open-torrent-address-modal.js' const TorrentListPage = require('./torrent-list-page')
import removeTorrentModal from '../components/remove-torrent-modal.js'
import updateAvailableModal from '../components/update-available-modal.js'
import unsupportedMediaModal from '../components/unsupported-media-modal.js'
import deleteAllTorrentsModal from '../components/delete-all-torrents-modal.js'
const darkBaseTheme = { default: darkBaseTheme$0 }.default
const getMuiTheme = getMuiTheme$0.default
const MuiThemeProvider = muiThemeProvider.default
const Views = { const Views = {
home: createGetter(() => TorrentListPage), 'home': createGetter(() => TorrentListPage),
player: createGetter(() => playerPage), 'player': createGetter(() => require('./player-page')),
'create-torrent': createGetter(() => createTorrentPage), 'create-torrent': createGetter(() => require('./create-torrent-page')),
preferences: createGetter(() => preferencesPage) 'preferences': createGetter(() => require('./preferences-page'))
} }
const Modals = { const Modals = {
'open-torrent-address-modal': createGetter(() => openTorrentAddressModal), 'open-torrent-address-modal': createGetter(
'remove-torrent-modal': createGetter(() => removeTorrentModal), () => require('../components/open-torrent-address-modal')
'update-available-modal': createGetter(() => updateAvailableModal), ),
'unsupported-media-modal': createGetter(() => unsupportedMediaModal), 'remove-torrent-modal': createGetter(() => require('../components/remove-torrent-modal')),
'delete-all-torrents-modal': createGetter(() => deleteAllTorrentsModal) 'update-available-modal': createGetter(() => require('../components/update-available-modal')),
'unsupported-media-modal': createGetter(() => require('../components/unsupported-media-modal'))
} }
const fontFamily = process.platform === 'win32' const fontFamily = process.platform === 'win32'
@@ -48,7 +43,7 @@ darkBaseTheme.palette.accent3Color = colors.redA100
let darkMuiTheme let darkMuiTheme
let lightMuiTheme let lightMuiTheme
export default class App extends React.Component { class App extends React.Component {
render () { render () {
const state = this.props.state const state = this.props.state
@@ -89,24 +84,24 @@ export default class App extends React.Component {
const recentErrors = state.errors.filter((x) => now - x.time < 5000) const recentErrors = state.errors.filter((x) => now - x.time < 5000)
const hasErrors = recentErrors.length > 0 const hasErrors = recentErrors.length > 0
const errorElems = recentErrors.map((error, i) => <div key={i} className='error'>{error.message}</div>) const errorElems = recentErrors.map(function (error, i) {
return (<div key={i} className='error'>{error.message}</div>)
})
return ( return (
<div <div key='errors'
key='errors' className={'error-popover ' + (hasErrors ? 'visible' : 'hidden')}>
className={'error-popover ' + (hasErrors ? 'visible' : 'hidden')}
>
<div key='title' className='title'>Error</div> <div key='title' className='title'>Error</div>
{errorElems} {errorElems}
</div> </div>
) )
} }
async getModal () { getModal () {
const state = this.props.state const state = this.props.state
if (!state.modal) return if (!state.modal) return
if (!lightMuiTheme) { if (!lightMuiTheme) {
const lightBaseTheme = (await import('material-ui/styles/baseThemes/lightBaseTheme')).default const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default
lightBaseTheme.fontFamily = fontFamily lightBaseTheme.fontFamily = fontFamily
lightBaseTheme.userAgent = false lightBaseTheme.userAgent = false
lightMuiTheme = getMuiTheme(lightBaseTheme) lightMuiTheme = getMuiTheme(lightBaseTheme)
@@ -131,3 +126,5 @@ export default class App extends React.Component {
return (<View state={state} />) return (<View state={state} />)
} }
} }
module.exports = App

View File

@@ -1,25 +1,22 @@
import createTorrent from 'create-torrent' const createTorrent = require('create-torrent')
import path from 'path' const path = require('path')
import prettyBytes from 'prettier-bytes' const prettyBytes = require('prettier-bytes')
import * as React from 'react' const React = require('react')
import flatButton from 'material-ui/FlatButton'
import raisedButton from 'material-ui/RaisedButton'
import textField from 'material-ui/TextField'
import checkbox from 'material-ui/Checkbox'
import { dispatch, dispatcher } from '../lib/dispatcher.js' const {dispatch, dispatcher} = require('../lib/dispatcher')
import CreateTorrentErrorPage from '../components/create-torrent-error-page.js'
import Heading from '../components/heading.js'
import ShowMore from '../components/show-more.js'
const FlatButton = flatButton.default const FlatButton = require('material-ui/FlatButton').default
const RaisedButton = raisedButton.default const RaisedButton = require('material-ui/RaisedButton').default
const TextField = textField.default const TextField = require('material-ui/TextField').default
const Checkbox = checkbox.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. // Shows a basic UI to create a torrent, from an already-selected file or folder.
// Includes a "Show Advanced..." button and more advanced UI. // Includes a "Show Advanced..." button and more advanced UI.
export default class CreateTorrentPage extends React.Component { class CreateTorrentPage extends React.Component {
constructor (props) { constructor (props) {
super(props) super(props)
@@ -38,7 +35,7 @@ export default class CreateTorrentPage extends React.Component {
// Then, exclude .DS_Store and other dotfiles // Then, exclude .DS_Store and other dotfiles
const files = info.files const files = info.files
.filter((f) => !containsDots(f.path, pathPrefix)) .filter((f) => !containsDots(f.path, pathPrefix))
.map((f) => ({ name: f.name, path: f.path, size: f.size })) .map((f) => ({name: f.name, path: f.path, size: f.size}))
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />) if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
// Then, use the name of the base folder (or sole file, for a single file torrent) // Then, use the name of the base folder (or sole file, for a single file torrent)
@@ -68,9 +65,9 @@ export default class CreateTorrentPage extends React.Component {
} }
// Create React event handlers only once // Create React event handlers only once
this.handleSetIsPrivate = (_, isPrivate) => this.setState({ isPrivate }) this.setIsPrivate = (_, isPrivate) => this.setState({isPrivate})
this.handleSetComment = (_, comment) => this.setState({ comment }) this.setComment = (_, comment) => this.setState({comment})
this.handleSetTrackers = (_, trackers) => this.setState({ trackers }) this.setTrackers = (_, trackers) => this.setState({trackers})
this.handleSubmit = handleSubmit.bind(this) this.handleSubmit = handleSubmit.bind(this)
} }
@@ -97,8 +94,7 @@ export default class CreateTorrentPage extends React.Component {
marginBottom: 10 marginBottom: 10
}} }}
hideLabel='Hide advanced settings...' hideLabel='Hide advanced settings...'
showLabel='Show advanced settings...' showLabel='Show advanced settings...'>
>
{this.renderAdvanced()} {this.renderAdvanced()}
</ShowMore> </ShowMore>
<div className='float-right'> <div className='float-right'>
@@ -108,14 +104,12 @@ export default class CreateTorrentPage extends React.Component {
style={{ style={{
marginRight: 10 marginRight: 10
}} }}
onClick={dispatcher('cancel')} onClick={dispatcher('cancel')} />
/>
<RaisedButton <RaisedButton
className='control create-torrent-button' className='control create-torrent-button'
label='Create Torrent' label='Create Torrent'
primary primary
onClick={this.handleSubmit} onClick={this.handleSubmit} />
/>
</div> </div>
</div> </div>
) )
@@ -147,10 +141,9 @@ export default class CreateTorrentPage extends React.Component {
<label>Private:</label> <label>Private:</label>
<Checkbox <Checkbox
className='torrent-is-private control' className='torrent-is-private control'
style={{ display: '' }} style={{display: ''}}
checked={this.state.isPrivate} checked={this.state.isPrivate}
onCheck={this.handleSetIsPrivate} onCheck={this.setIsPrivate} />
/>
</div> </div>
<div key='trackers' className='torrent-attribute'> <div key='trackers' className='torrent-attribute'>
<label>Trackers:</label> <label>Trackers:</label>
@@ -162,8 +155,7 @@ export default class CreateTorrentPage extends React.Component {
rows={2} rows={2}
rowsMax={10} rowsMax={10}
value={this.state.trackers} value={this.state.trackers}
onChange={this.handleSetTrackers} onChange={this.setTrackers} />
/>
</div> </div>
<div key='comment' className='torrent-attribute'> <div key='comment' className='torrent-attribute'>
<label>Comment:</label> <label>Comment:</label>
@@ -176,8 +168,7 @@ export default class CreateTorrentPage extends React.Component {
rows={2} rows={2}
rowsMax={10} rowsMax={10}
value={this.state.comment} value={this.state.comment}
onChange={this.handleSetComment} onChange={this.setComment} />
/>
</div> </div>
<div key='files' className='torrent-attribute'> <div key='files' className='torrent-attribute'>
<label>Files:</label> <label>Files:</label>
@@ -200,14 +191,9 @@ function handleSubmit () {
path: this.state.basePath, path: this.state.basePath,
files: this.state.files, files: this.state.files,
announce: announceList, announce: announceList,
private: this.state.isPrivate,
comment: this.state.comment.trim() comment: this.state.comment.trim()
} }
// If torrent is not private, leave private flag unset. This ensures that
// the torrent info hash will match the result generated by other tools,
// including webtorrent-cli.
if (this.state.isPrivate) options.private = true
dispatch('createTorrent', options) dispatch('createTorrent', options)
} }
@@ -226,3 +212,5 @@ function containsDots (path, pathPrefix) {
const suffix = path.substring(pathPrefix.length).replace(/\\/g, '/') const suffix = path.substring(pathPrefix.length).replace(/\\/g, '/')
return ('/' + suffix).includes('/.') return ('/' + suffix).includes('/.')
} }
module.exports = CreateTorrentPage

View File

@@ -1,16 +1,15 @@
import * as React from 'react' const React = require('react')
import bitfield from 'bitfield' const Bitfield = require('bitfield')
import prettyBytes from 'prettier-bytes' const prettyBytes = require('prettier-bytes')
const zeroFill = require('zero-fill')
import TorrentSummary from '../lib/torrent-summary.js' const TorrentSummary = require('../lib/torrent-summary')
import Playlist from '../lib/playlist.js' const Playlist = require('../lib/playlist')
import { dispatch, dispatcher } from '../lib/dispatcher.js' const {dispatch, dispatcher} = require('../lib/dispatcher')
import config from '../../config.js' const config = require('../../config')
import { calculateEta } from '../lib/time.js'
const BitField = bitfield.default // Shows a streaming video player. Standard features + Chromecast + Airplay
module.exports = class Player extends React.Component {
export default class Player extends React.Component {
render () { render () {
// Show the video as large as will fit in the window, play immediately // Show the video as large as will fit in the window, play immediately
// If the video is on Chromecast or Airplay, show a title screen instead // If the video is on Chromecast or Airplay, show a title screen instead
@@ -21,8 +20,7 @@ export default class Player extends React.Component {
<div <div
className='player' className='player'
onWheel={handleVolumeWheel} onWheel={handleVolumeWheel}
onMouseMove={dispatcher('mediaMouseMoved')} onMouseMove={dispatcher('mediaMouseMoved')}>
>
{showVideo ? renderMedia(state) : renderCastScreen(state)} {showVideo ? renderMedia(state) : renderCastScreen(state)}
{showControls ? renderPlayerControls(state) : null} {showControls ? renderPlayerControls(state) : null}
</div> </div>
@@ -97,31 +95,24 @@ function renderMedia (state) {
delete file.selectedSubtitle delete file.selectedSubtitle
} }
// Switch to selected audio track
const audioTracks = mediaElement.audioTracks || []
for (let j = 0; j < audioTracks.length; j++) {
const isSelectedTrack = j === state.playing.audioTracks.selectedIndex
audioTracks[j].enabled = isSelectedTrack
}
state.playing.volume = mediaElement.volume state.playing.volume = mediaElement.volume
} }
// Add subtitles to the <video> tag // Add subtitles to the <video> tag
const trackTags = [] const trackTags = []
if (state.playing.subtitles.selectedIndex >= 0) { if (state.playing.subtitles.selectedIndex >= 0) {
state.playing.subtitles.tracks.forEach((track, i) => { 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 const isSelected = state.playing.subtitles.selectedIndex === i
trackTags.push( trackTags.push(
<track <track
key={i} key={i}
default={isSelected} default={isSelected ? 'default' : ''}
label={track.label} label={track.label}
kind='subtitles' type='subtitles'
src={track.buffer} src={track.buffer} />
/>
) )
}) }
} }
// Create the <audio> or <video> tag // Create the <audio> or <video> tag
@@ -130,14 +121,13 @@ function renderMedia (state) {
<MediaTagName <MediaTagName
src={Playlist.getCurrentLocalURL(state)} src={Playlist.getCurrentLocalURL(state)}
onDoubleClick={dispatcher('toggleFullScreen')} onDoubleClick={dispatcher('toggleFullScreen')}
onClick={dispatcher('playPause')}
onLoadedMetadata={onLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onEnded={onEnded} onEnded={onEnded}
onStalled={dispatcher('mediaStalled')} onStalled={dispatcher('mediaStalled')}
onError={dispatcher('mediaError')} onError={dispatcher('mediaError')}
onTimeUpdate={dispatcher('mediaTimeUpdate')} onTimeUpdate={dispatcher('mediaTimeUpdate')}
onEncrypted={dispatcher('mediaEncrypted')} onEncrypted={dispatcher('mediaEncrypted')}
> onCanPlay={onCanPlay}>
{trackTags} {trackTags}
</MediaTagName> </MediaTagName>
) )
@@ -147,66 +137,42 @@ function renderMedia (state) {
<div <div
key='letterbox' key='letterbox'
className='letterbox' className='letterbox'
onMouseMove={dispatcher('mediaMouseMoved')} onMouseMove={dispatcher('mediaMouseMoved')}>
>
{mediaTag} {mediaTag}
{renderOverlay(state)} {renderOverlay(state)}
</div> </div>
) )
// As soon as we know the video dimensions, resize the window
function onLoadedMetadata (e) { function onLoadedMetadata (e) {
const mediaElement = e.target if (state.playing.type !== 'video') return
const video = e.target
// check if we can decode video and audio track const dimensions = {
if (state.playing.type === 'video') { width: video.videoWidth,
if (mediaElement.videoTracks.length === 0) { height: video.videoHeight
dispatch('mediaError', 'Video codec unsupported')
}
if (mediaElement.audioTracks.length === 0) {
dispatch('mediaError', 'Audio codec unsupported')
}
dispatch('mediaSuccess')
const dimensions = {
width: mediaElement.videoWidth,
height: mediaElement.videoHeight
}
// As soon as we know the video dimensions, resize the window
dispatch('setDimensions', dimensions)
// set audioTracks
const tracks = []
for (let i = 0; i < mediaElement.audioTracks.length; i++) {
tracks.push({
label: mediaElement.audioTracks[i].label || `Track ${i + 1}`,
language: mediaElement.audioTracks[i].language
})
}
state.playing.audioTracks.tracks = tracks
state.playing.audioTracks.selectedIndex = 0
}
// check if we can decode audio track
if (state.playing.type === 'audio') {
if (mediaElement.audioTracks.length === 0) {
dispatch('mediaError', 'Audio codec unsupported')
}
dispatch('mediaSuccess')
} }
dispatch('setDimensions', dimensions)
} }
function onEnded () { function onEnded (e) {
if (Playlist.hasNext(state)) { if (Playlist.hasNext(state)) {
dispatch('nextTrack') dispatch('nextTrack')
} else { } else {
// When the last video completes, pause the video instead of looping // When the last video completes, pause the video instead of looping
state.playing.isPaused = true state.playing.isPaused = true
if (state.window.isFullScreen) dispatch('toggleFullScreen') }
}
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()
} }
} }
} }
@@ -237,41 +203,28 @@ function renderOverlay (state) {
) )
} }
/**
* Render track or disk number string
* @param common metadata.common part
* @param key should be either 'track' or 'disk'
* @return track or disk number metadata as JSX block
*/
function renderTrack (common, key) {
// Audio metadata: track-number
if (common[key] && common[key].no) {
let str = `${common[key].no}`
if (common[key].of) {
str += ` of ${common[key].of}`
}
const style = { textTransform: 'capitalize' }
return (
<div className={`audio-${key}`}>
<label style={style}>{key}</label> {str}
</div>
)
}
}
function renderAudioMetadata (state) { function renderAudioMetadata (state) {
const fileSummary = state.getPlayingFileSummary() const fileSummary = state.getPlayingFileSummary()
if (!fileSummary.audioInfo) return if (!fileSummary.audioInfo) return
const common = fileSummary.audioInfo.common || {} const info = fileSummary.audioInfo
// Get audio track info // Get audio track info
const title = common.title ? common.title : fileSummary.name 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 // Show a small info box in the middle of the screen with title/album/etc
const elems = [] const elems = []
// Audio metadata: artist(s)
const artist = common.artist || common.albumartist
if (artist) { if (artist) {
elems.push(( elems.push((
<div key='artist' className='audio-artist'> <div key='artist' className='audio-artist'>
@@ -279,90 +232,17 @@ function renderAudioMetadata (state) {
</div> </div>
)) ))
} }
if (album) {
// Audio metadata: disk & track-number
const count = ['track', 'disk']
count.forEach(key => {
const nrElem = renderTrack(common, key)
if (nrElem) {
elems.push(nrElem)
}
})
// Audio metadata: album
if (common.album) {
elems.push(( elems.push((
<div key='album' className='audio-album'> <div key='album' className='audio-album'>
<label>Album</label>{common.album} <label>Album</label>{album}
</div> </div>
)) ))
} }
if (track) {
// Audio metadata: year
if (common.year) {
elems.push(( elems.push((
<div key='year' className='audio-year'> <div key='track' className='audio-track'>
<label>Year</label>{common.year} <label>Track</label>{track}
</div>
))
}
// Audio metadata: release information (label & catalog-number)
if (common.label || common.catalognumber) {
const releaseInfo = []
if (common.label && common.catalognumber &&
common.label.length === common.catalognumber.length) {
// Assume labels & catalog-numbers are pairs
for (let n = 0; n < common.label.length; ++n) {
releaseInfo.push(common.label[0] + ' / ' + common.catalognumber[n])
}
} else {
if (common.label) {
releaseInfo.push(...common.label)
}
if (common.catalognumber) {
releaseInfo.push(...common.catalognumber)
}
}
elems.push((
<div key='release' className='audio-release'>
<label>Release</label>{releaseInfo.join(', ')}
</div>
))
}
// Audio metadata: format
const format = []
fileSummary.audioInfo.format = fileSummary.audioInfo.format || ''
if (fileSummary.audioInfo.format.container) {
format.push(fileSummary.audioInfo.format.container)
}
if (fileSummary.audioInfo.format.codec &&
fileSummary.audioInfo.format.container !== fileSummary.audioInfo.format.codec) {
format.push(fileSummary.audioInfo.format.codec)
}
if (fileSummary.audioInfo.format.bitrate) {
format.push(Math.round(fileSummary.audioInfo.format.bitrate / 1000) + ' kbit/s') // 128 kbit/s
}
if (fileSummary.audioInfo.format.sampleRate) {
format.push(Math.round(fileSummary.audioInfo.format.sampleRate / 100) / 10 + ' kHz')
}
if (fileSummary.audioInfo.format.bitsPerSample) {
format.push(fileSummary.audioInfo.format.bitsPerSample + '-bit')
}
if (format.length > 0) {
elems.push((
<div key='format' className='audio-format'>
<label>Format</label>{format.join(', ')}
</div>
))
}
// Audio metadata: comments
if (common.comment) {
elems.push((
<div key='comments' className='audio-comments'>
<label>Comments</label>{common.comment.join(' / ')}
</div> </div>
)) ))
} }
@@ -393,9 +273,9 @@ function renderLoadingSpinner (state) {
return ( return (
<div key='loading' className='media-stalled'> <div key='loading' className='media-stalled'>
<div key='loading-spinner' className='loading-spinner' /> <div key='loading-spinner' className='loading-spinner'>&nbsp;</div>
<div key='loading-progress' className='loading-status ellipsis'> <div key='loading-progress' className='loading-status ellipsis'>
<span><span className='progress'>{fileProgress}%</span> downloaded</span> <span className='progress'>{fileProgress}%</span> downloaded
<span> {prettyBytes(prog.downloadSpeed || 0)}/s</span> <span> {prettyBytes(prog.downloadSpeed || 0)}/s</span>
<span> {prettyBytes(prog.uploadSpeed || 0)}/s</span> <span> {prettyBytes(prog.uploadSpeed || 0)}/s</span>
</div> </div>
@@ -423,76 +303,28 @@ function renderCastScreen (state) {
isCast = false isCast = false
} else if (state.playing.location === 'error') { } else if (state.playing.location === 'error') {
castIcon = 'error_outline' castIcon = 'error_outline'
castType = 'Unable to Play' castType = 'Error'
isCast = false isCast = false
} }
const isStarting = state.playing.location.endsWith('-pending') const isStarting = state.playing.location.endsWith('-pending')
const castName = state.playing.castName const castName = state.playing.castName
const fileName = state.getPlayingFileSummary().name || ''
let castStatus let castStatus
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...' if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
else castStatus = '' else castStatus = ''
const prog = state.getPlayingTorrentSummary().progress || {}
// Show a nice title image, if possible // Show a nice title image, if possible
const style = { const style = {
backgroundImage: cssBackgroundImagePoster(state) backgroundImage: cssBackgroundImagePoster(state)
} }
function renderEta (total, downloaded) {
const missing = (total || 0) - (downloaded || 0)
const downloadSpeed = prog.downloadSpeed || 0
if (downloadSpeed === 0 || missing === 0) return
const etaStr = calculateEta(missing, downloadSpeed)
return (<span>{etaStr}</span>)
}
function renderDownloadProgress () {
if (!prog.files) return
const fileProg = prog.files[state.playing.fileIndex]
const fileProgress = fileProg.numPiecesPresent / fileProg.numPieces
const fileLength = state.getPlayingFileSummary().length
const fileDownloaded = fileProgress * fileLength
const progress = Math.round(100 * fileProgress)
const total = prettyBytes(fileLength)
const completed = prettyBytes(fileDownloaded)
const downloadSpeed = prettyBytes(prog.downloadSpeed || 0)
const uploadSpeed = prettyBytes(prog.uploadSpeed || 0)
let sizes
if (fileProgress < 1) {
sizes = <span> | {completed} / {total}</span>
} else {
sizes = <span> | {completed}</span>
}
return (
<div key='download-progress'>
<span className='progress'>{progress}% downloaded {sizes}</span>
<br />
<span>↓ {downloadSpeed}/s ↑ {uploadSpeed}/s | {prog.numPeers || 0} peer(s)</span>
<br />
{renderEta(fileLength, fileDownloaded)}
</div>
)
}
return ( return (
<div key='cast' className='letterbox' style={style}> <div key='cast' className='letterbox' style={style}>
<div className='cast-screen'> <div className='cast-screen'>
<i className='icon'>{castIcon}</i> <i className='icon'>{castIcon}</i>
<div key='type' className='cast-type'>{castType}</div> <div key='type' className='cast-type'>{castType}</div>
<div key='status' className='cast-status'>{castStatus}</div> <div key='status' className='cast-status'>{castStatus}</div>
<div key='name' className='name'>{fileName}</div>
{renderDownloadProgress()}
</div> </div>
</div> </div>
) )
@@ -501,16 +333,15 @@ function renderCastScreen (state) {
function renderCastOptions (state) { function renderCastOptions (state) {
if (!state.devices.castMenu) return if (!state.devices.castMenu) return
const { location, devices } = state.devices.castMenu const {location, devices} = state.devices.castMenu
const player = state.devices[location] const player = state.devices[location]
const items = devices.map((device, ix) => { const items = devices.map(function (device, ix) {
const isSelected = player.device === device const isSelected = player.device === device
const name = device.name const name = device.name
return ( return (
<li key={ix} onClick={dispatcher('selectCastDevice', ix)}> <li key={ix} onClick={dispatcher('selectCastDevice', ix)}>
<i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i> <i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
{' '}
{name} {name}
</li> </li>
) )
@@ -527,7 +358,7 @@ function renderSubtitleOptions (state) {
const subtitles = state.playing.subtitles const subtitles = state.playing.subtitles
if (!subtitles.tracks.length || !subtitles.showMenu) return if (!subtitles.tracks.length || !subtitles.showMenu) return
const items = subtitles.tracks.map((track, ix) => { const items = subtitles.tracks.map(function (track, ix) {
const isSelected = state.playing.subtitles.selectedIndex === ix const isSelected = state.playing.subtitles.selectedIndex === ix
return ( return (
<li key={ix} onClick={dispatcher('selectSubtitle', ix)}> <li key={ix} onClick={dispatcher('selectSubtitle', ix)}>
@@ -550,27 +381,6 @@ function renderSubtitleOptions (state) {
) )
} }
function renderAudioTrackOptions (state) {
const audioTracks = state.playing.audioTracks
if (!audioTracks.tracks.length || !audioTracks.showMenu) return
const items = audioTracks.tracks.map((track, ix) => {
const isSelected = state.playing.audioTracks.selectedIndex === ix
return (
<li key={ix} onClick={dispatcher('selectAudioTrack', ix)}>
<i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
{track.label}
</li>
)
})
return (
<ul key='audio-track-options' className='options-list'>
{items}
</ul>
)
}
function renderPlayerControls (state) { function renderPlayerControls (state) {
const positionPercent = 100 * state.playing.currentTime / state.playing.duration const positionPercent = 100 * state.playing.currentTime / state.playing.duration
const playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' } const playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
@@ -579,63 +389,50 @@ function renderPlayerControls (state) {
: state.playing.subtitles.selectedIndex >= 0 : state.playing.subtitles.selectedIndex >= 0
? 'active' ? 'active'
: '' : ''
const multiAudioClass = state.playing.audioTracks.tracks.length > 1
? 'active'
: 'disabled'
const prevClass = Playlist.hasPrevious(state) ? '' : 'disabled' const prevClass = Playlist.hasPrevious(state) ? '' : 'disabled'
const nextClass = Playlist.hasNext(state) ? '' : 'disabled' const nextClass = Playlist.hasNext(state) ? '' : 'disabled'
const elements = [ const elements = [
renderPreview(state),
<div key='playback-bar' className='playback-bar'> <div key='playback-bar' className='playback-bar'>
{renderLoadingBar(state)} {renderLoadingBar(state)}
<div <div
key='cursor' key='cursor'
className='playback-cursor' className='playback-cursor'
style={playbackCursorStyle} style={playbackCursorStyle} />
/>
<div <div
key='scrub-bar' key='scrub-bar'
className='scrub-bar' className='scrub-bar'
draggable='true' draggable='true'
onMouseMove={handleScrubPreview}
onMouseOut={clearPreview}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={handleScrub} onClick={handleScrub}
onDrag={handleScrub} onDrag={handleScrub} />
/>
</div>, </div>,
<i <i
key='skip-previous' key='skip-previous'
className={'icon skip-previous float-left ' + prevClass} className={'icon skip-previous float-left ' + prevClass}
onClick={dispatcher('previousTrack')} onClick={dispatcher('previousTrack')}>
>
skip_previous skip_previous
</i>, </i>,
<i <i
key='play' key='play'
className='icon play-pause float-left' className='icon play-pause float-left'
onClick={dispatcher('playPause')} onClick={dispatcher('playPause')}>
>
{state.playing.isPaused ? 'play_arrow' : 'pause'} {state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>, </i>,
<i <i
key='skip-next' key='skip-next'
className={'icon skip-next float-left ' + nextClass} className={'icon skip-next float-left ' + nextClass}
onClick={dispatcher('nextTrack')} onClick={dispatcher('nextTrack')}>
>
skip_next skip_next
</i>, </i>,
<i <i
key='fullscreen' key='fullscreen'
className='icon fullscreen float-right' className='icon fullscreen float-right'
onClick={dispatcher('toggleFullScreen')} onClick={dispatcher('toggleFullScreen')}>
>
{state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'} {state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
</i> </i>
] ]
@@ -646,18 +443,9 @@ function renderPlayerControls (state) {
<i <i
key='subtitles' key='subtitles'
className={'icon closed-caption float-right ' + captionsClass} className={'icon closed-caption float-right ' + captionsClass}
onClick={handleSubtitles} onClick={handleSubtitles}>
>
closed_caption closed_caption
</i> </i>
), (
<i
key='audio-tracks'
className={'icon multi-audio float-right ' + multiAudioClass}
onClick={handleAudioTracks}
>
library_music
</i>
)) ))
} }
@@ -668,11 +456,11 @@ function renderPlayerControls (state) {
// Add the cast buttons. Icons for each cast type, connected/disconnected: // Add the cast buttons. Icons for each cast type, connected/disconnected:
const buttonIcons = { const buttonIcons = {
chromecast: { true: 'cast_connected', false: 'cast' }, 'chromecast': {true: 'cast_connected', false: 'cast'},
airplay: { true: 'airplay', false: 'airplay' }, 'airplay': {true: 'airplay', false: 'airplay'},
dlna: { true: 'tv', false: 'tv' } 'dlna': {true: 'tv', false: 'tv'}
} }
castTypes.forEach(castType => { castTypes.forEach(function (castType) {
// Do we show this button (eg. the Chromecast button) at all? // Do we show this button (eg. the Chromecast button) at all?
const isCasting = state.playing.location.startsWith(castType) const isCasting = state.playing.location.startsWith(castType)
const player = state.devices[castType] const player = state.devices[castType]
@@ -699,8 +487,7 @@ function renderPlayerControls (state) {
<i <i
key={castType} key={castType}
className={'icon device float-right ' + buttonClass} className={'icon device float-right ' + buttonClass}
onClick={buttonHandler} onClick={buttonHandler}>
>
{buttonIcon} {buttonIcon}
</i> </i>
)) ))
@@ -709,14 +496,10 @@ function renderPlayerControls (state) {
// Render volume slider // Render volume slider
const volume = state.playing.volume const volume = state.playing.volume
const volumeIcon = 'volume_' + ( const volumeIcon = 'volume_' + (
volume === 0 volume === 0 ? 'off'
? 'off' : volume < 0.3 ? 'mute'
: volume < 0.3 : volume < 0.6 ? 'down'
? 'mute' : 'up')
: volume < 0.6
? 'down'
: 'up'
)
const volumeStyle = { const volumeStyle = {
background: '-webkit-gradient(linear, left top, right top, ' + background: '-webkit-gradient(linear, left top, right top, ' +
'color-stop(' + (volume * 100) + '%, #eee), ' + 'color-stop(' + (volume * 100) + '%, #eee), ' +
@@ -727,8 +510,7 @@ function renderPlayerControls (state) {
<div key='volume' className='volume float-left'> <div key='volume' className='volume float-left'>
<i <i
className='icon volume-icon float-left' className='icon volume-icon float-left'
onMouseDown={handleVolumeMute} onMouseDown={handleVolumeMute}>
>
{volumeIcon} {volumeIcon}
</i> </i>
<input <input
@@ -736,14 +518,13 @@ function renderPlayerControls (state) {
type='range' min='0' max='1' step='0.05' type='range' min='0' max='1' step='0.05'
value={volume} value={volume}
onChange={handleVolumeScrub} onChange={handleVolumeScrub}
style={volumeStyle} style={volumeStyle} />
/>
</div> </div>
)) ))
// Show video playback progress // Show video playback progress
const currentTimeStr = formatTime(state.playing.currentTime, state.playing.duration) const currentTimeStr = formatTime(state.playing.currentTime)
const durationStr = formatTime(state.playing.duration, state.playing.duration) const durationStr = formatTime(state.playing.duration)
elements.push(( elements.push((
<span key='time' className='time float-left'> <span key='time' className='time float-left'>
{currentTimeStr} / {durationStr} {currentTimeStr} / {durationStr}
@@ -759,31 +540,24 @@ function renderPlayerControls (state) {
)) ))
} }
const emptyImage = new window.Image(0, 0) return (
emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs%3D' <div key='controls' className='controls'
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}>
{elements}
{renderCastOptions(state)}
{renderSubtitleOptions(state)}
</div>
)
function handleDragStart (e) { function handleDragStart (e) {
// Prevent the cursor from changing, eg to a green + icon on Mac
if (e.dataTransfer) { if (e.dataTransfer) {
const dt = e.dataTransfer const dt = e.dataTransfer
// Prevent the cursor from changing, eg to a green + icon on Mac
dt.effectAllowed = 'none' dt.effectAllowed = 'none'
// Prevent ghost image
dt.setDragImage(emptyImage, 0, 0)
} }
} }
// Handles a scrub hover (preview another position in the video)
function handleScrubPreview (e) {
// Only show for videos
if (!e.clientX || state.playing.type !== 'video') return
dispatch('mediaMouseMoved')
dispatch('preview', e.clientX)
}
function clearPreview () {
if (state.playing.type !== 'video') return
dispatch('clearPreview')
}
// Handles a click or drag to scrub (jump to another position in the video) // Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) { function handleScrub (e) {
if (!e.clientX) return if (!e.clientX) return
@@ -795,7 +569,7 @@ function renderPlayerControls (state) {
} }
// Handles volume muting and Unmuting // Handles volume muting and Unmuting
function handleVolumeMute () { function handleVolumeMute (e) {
if (state.playing.volume === 0.0) { if (state.playing.volume === 0.0) {
dispatch('setVolume', 1.0) dispatch('setVolume', 1.0)
} else { } else {
@@ -816,73 +590,6 @@ function renderPlayerControls (state) {
dispatch('toggleSubtitlesMenu') dispatch('toggleSubtitlesMenu')
} }
} }
function handleAudioTracks () {
dispatch('toggleAudioTracksMenu')
}
return (
<div
key='controls' className='controls'
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}
>
{elements}
{renderCastOptions(state)}
{renderSubtitleOptions(state)}
{renderAudioTrackOptions(state)}
</div>
)
}
function renderPreview (state) {
const { previewXCoord = null } = state.playing
// Calculate time from x-coord as fraction of track width
const windowWidth = document.querySelector('body').clientWidth
const fraction = previewXCoord / windowWidth
const time = fraction * state.playing.duration /* seconds */
const height = 70
let width = 0
const previewEl = document.querySelector('video#preview')
if (previewEl !== null && previewXCoord !== null) {
previewEl.currentTime = time
// Auto adjust width to maintain video aspect ratio
width = Math.floor((previewEl.videoWidth / previewEl.videoHeight) * height)
}
// Center preview window on mouse cursor,
// while avoiding falling off the left or right edges
const xPos = Math.min(Math.max(previewXCoord - (width / 2), 5), windowWidth - width - 5)
return (
<div
key='preview' style={{
position: 'absolute',
bottom: 50,
left: xPos,
display: previewXCoord == null && 'none' // Hide preview when XCoord unset
}}
>
<div style={{ width, height, backgroundColor: 'black' }}>
<video
src={Playlist.getCurrentLocalURL(state)}
id='preview'
style={{ border: '1px solid lightgrey', borderRadius: 2 }}
/>
</div>
<p
style={{
textAlign: 'center', margin: 5, textShadow: '0 0 2px rgba(0,0,0,.5)', color: '#eee'
}}
>
{formatTime(time, state.playing.duration)}
</p>
</div>
)
} }
// Renders the loading bar. Shows which parts of the torrent are loaded, which // Renders the loading bar. Shows which parts of the torrent are loaded, which
@@ -904,9 +611,9 @@ function renderLoadingBar (state) {
const parts = [] const parts = []
let lastPiecePresent = false let lastPiecePresent = false
for (let i = fileProg.startPiece; i <= fileProg.endPiece; i++) { for (let i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
const partPresent = BitField.prototype.get.call(prog.bitfield, i) const partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
if (partPresent && !lastPiecePresent) { if (partPresent && !lastPiecePresent) {
parts.push({ start: i - fileProg.startPiece, count: 1 }) parts.push({start: i - fileProg.startPiece, count: 1})
} else if (partPresent) { } else if (partPresent) {
parts[parts.length - 1].count++ parts[parts.length - 1].count++
} }
@@ -914,7 +621,7 @@ function renderLoadingBar (state) {
} }
// Output some bars to show which parts of the file are loaded // Output some bars to show which parts of the file are loaded
const loadingBarElems = parts.map((part, i) => { const loadingBarElems = parts.map(function (part, i) {
const style = { const style = {
left: (100 * part.start / fileProg.numPieces) + '%', left: (100 * part.start / fileProg.numPieces) + '%',
width: (100 * part.count / fileProg.numPieces) + '%' width: (100 * part.count / fileProg.numPieces) + '%'
@@ -939,19 +646,17 @@ function cssBackgroundImageDarkGradient () {
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' 'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
} }
function formatTime (time, total) { function formatTime (time) {
if (typeof time !== 'number' || Number.isNaN(time)) { if (typeof time !== 'number' || Number.isNaN(time)) {
return '0:00' return '0:00'
} }
const totalHours = Math.floor(total / 3600) let hours = Math.floor(time / 3600)
const totalMinutes = Math.floor(total / 60)
const hours = Math.floor(time / 3600)
let minutes = Math.floor(time % 3600 / 60) let minutes = Math.floor(time % 3600 / 60)
if (totalMinutes > 9 && minutes < 10) { if (hours > 0) {
minutes = '0' + minutes minutes = zeroFill(2, minutes)
} }
const seconds = `0${Math.floor(time % 60)}`.slice(-2) let seconds = zeroFill(2, Math.floor(time % 60))
return (totalHours > 0 ? hours + ':' : '') + minutes + ':' + seconds return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
} }

View File

@@ -1,18 +1,16 @@
import * as React from 'react' const path = require('path')
import PropTypes from 'prop-types' const React = require('react')
import * as colors from 'material-ui/styles/colors'
import checkbox from 'material-ui/Checkbox'
import raisedButton from 'material-ui/RaisedButton'
import Heading from '../components/heading.js' const colors = require('material-ui/styles/colors')
import PathSelector from '../components/path-selector.js' const Checkbox = require('material-ui/Checkbox').default
import { dispatch } from '../lib/dispatcher.js' const RaisedButton = require('material-ui/RaisedButton').default
import config from '../../config.js' const Heading = require('../components/heading')
const PathSelector = require('../components/path-selector')
const Checkbox = checkbox.default const {dispatch} = require('../lib/dispatcher')
const RaisedButton = raisedButton.default const config = require('../../config')
export default class PreferencesPage extends React.Component { class PreferencesPage extends React.Component {
constructor (props) { constructor (props) {
super(props) super(props)
@@ -27,9 +25,6 @@ export default class PreferencesPage extends React.Component {
this.handleStartupChange = this.handleStartupChange =
this.handleStartupChange.bind(this) this.handleStartupChange.bind(this)
this.handleSoundNotificationsChange =
this.handleSoundNotificationsChange.bind(this)
} }
downloadPathSelector () { downloadPathSelector () {
@@ -38,12 +33,11 @@ export default class PreferencesPage extends React.Component {
<PathSelector <PathSelector
dialog={{ dialog={{
title: 'Select download directory', title: 'Select download directory',
properties: ['openDirectory'] properties: [ 'openDirectory' ]
}} }}
onChange={this.handleDownloadPathChange} onChange={this.handleDownloadPathChange}
title='Download location' title='Download location'
value={this.props.state.saved.prefs.downloadPath} value={this.props.state.unsaved.prefs.downloadPath} />
/>
</Preference> </Preference>
) )
} }
@@ -57,10 +51,9 @@ export default class PreferencesPage extends React.Component {
<Preference> <Preference>
<Checkbox <Checkbox
className='control' className='control'
checked={!this.props.state.saved.prefs.openExternalPlayer} checked={!this.props.state.unsaved.prefs.openExternalPlayer}
label='Play torrent media files using WebTorrent' label={'Play torrent media files using WebTorrent'}
onCheck={this.handleOpenExternalPlayerChange} onCheck={this.handleOpenExternalPlayerChange} />
/>
</Preference> </Preference>
) )
} }
@@ -69,29 +62,11 @@ export default class PreferencesPage extends React.Component {
dispatch('updatePreferences', 'openExternalPlayer', !isChecked) dispatch('updatePreferences', 'openExternalPlayer', !isChecked)
} }
highestPlaybackPriorityCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={this.props.state.saved.prefs.highestPlaybackPriority}
label='Highest Playback Priority'
onCheck={this.handleHighestPlaybackPriorityChange}
/>
<p>Pauses all active torrents to allow playback to use all of the available bandwidth.</p>
</Preference>
)
}
handleHighestPlaybackPriorityChange (e, isChecked) {
dispatch('updatePreferences', 'highestPlaybackPriority', isChecked)
}
externalPlayerPathSelector () { externalPlayerPathSelector () {
const playerPath = this.props.state.saved.prefs.externalPlayerPath const playerPath = this.props.state.unsaved.prefs.externalPlayerPath
const playerName = this.props.state.getExternalPlayerName() const playerName = this.props.state.getExternalPlayerName()
const description = this.props.state.saved.prefs.openExternalPlayer const description = this.props.state.unsaved.prefs.openExternalPlayer
? `Torrent media files will always play in ${playerName}.` ? `Torrent media files will always play in ${playerName}.`
: `Torrent media files will play in ${playerName} if WebTorrent cannot play them.` : `Torrent media files will play in ${playerName} if WebTorrent cannot play them.`
@@ -101,12 +76,12 @@ export default class PreferencesPage extends React.Component {
<PathSelector <PathSelector
dialog={{ dialog={{
title: 'Select media player app', title: 'Select media player app',
properties: ['openFile'] properties: [ 'openFile' ]
}} }}
displayValue={playerName}
onChange={this.handleExternalPlayerPathChange} onChange={this.handleExternalPlayerPathChange}
title='External player' title='External player'
value={playerPath} value={playerPath ? path.dirname(playerPath) : null} />
/>
</Preference> </Preference>
) )
} }
@@ -115,61 +90,8 @@ export default class PreferencesPage extends React.Component {
dispatch('updatePreferences', 'externalPlayerPath', filePath) dispatch('updatePreferences', 'externalPlayerPath', filePath)
} }
autoAddTorrentsCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={this.props.state.saved.prefs.autoAddTorrents}
label='Watch for new .torrent files and add them immediately'
onCheck={(e, value) => { this.handleAutoAddTorrentsChange(e, value) }}
/>
</Preference>
)
}
handleAutoAddTorrentsChange (e, isChecked) {
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
if (isChecked && !torrentsFolderPath) {
alert('Select a torrents folder first.') // eslint-disable-line
e.preventDefault()
return
}
dispatch('updatePreferences', 'autoAddTorrents', isChecked)
if (isChecked) {
dispatch('startFolderWatcher')
return
}
dispatch('stopFolderWatcher')
}
torrentsFolderPathSelector () {
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
return (
<Preference>
<PathSelector
dialog={{
title: 'Select folder to watch for new torrents',
properties: ['openDirectory']
}}
onChange={this.handleTorrentsFolderPathChange}
title='Folder to watch'
value={torrentsFolderPath}
/>
</Preference>
)
}
handleTorrentsFolderPathChange (filePath) {
dispatch('updatePreferences', 'torrentsFolderPath', filePath)
}
setDefaultAppButton () { setDefaultAppButton () {
const isFileHandler = this.props.state.saved.prefs.isFileHandler const isFileHandler = this.props.state.unsaved.prefs.isFileHandler
if (isFileHandler) { if (isFileHandler) {
return ( return (
<Preference> <Preference>
@@ -183,8 +105,7 @@ export default class PreferencesPage extends React.Component {
<RaisedButton <RaisedButton
className='control' className='control'
onClick={this.handleSetDefaultApp} onClick={this.handleSetDefaultApp}
label='Make WebTorrent the default' label='Make WebTorrent the default' />
/>
</Preference> </Preference>
) )
} }
@@ -193,40 +114,25 @@ export default class PreferencesPage extends React.Component {
dispatch('updatePreferences', 'startup', isChecked) dispatch('updatePreferences', 'startup', isChecked)
} }
setStartupCheckbox () { setStartupSection () {
if (config.IS_PORTABLE) { if (config.IS_PORTABLE) {
return return
} }
return ( return (
<Preference> <PreferencesSection title='Startup'>
<Checkbox <Preference>
className='control' <Checkbox
checked={this.props.state.saved.prefs.startup} className='control'
label='Open WebTorrent on startup' checked={this.props.state.unsaved.prefs.startup}
onCheck={this.handleStartupChange} label={'Open WebTorrent on startup.'}
/> onCheck={this.handleStartupChange}
</Preference> />
</Preference>
</PreferencesSection>
) )
} }
soundNotificationsCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={this.props.state.saved.prefs.soundNotifications}
label='Enable sounds'
onCheck={this.handleSoundNotificationsChange}
/>
</Preference>
)
}
handleSoundNotificationsChange (e, isChecked) {
dispatch('updatePreferences', 'soundNotifications', isChecked)
}
handleSetDefaultApp () { handleSetDefaultApp () {
dispatch('updatePreferences', 'isFileHandler', true) dispatch('updatePreferences', 'isFileHandler', true)
} }
@@ -239,23 +145,17 @@ export default class PreferencesPage extends React.Component {
} }
return ( return (
<div style={style}> <div style={style}>
<PreferencesSection title='Folders'> <PreferencesSection title='Downloads'>
{this.downloadPathSelector()} {this.downloadPathSelector()}
{this.autoAddTorrentsCheckbox()}
{this.torrentsFolderPathSelector()}
</PreferencesSection> </PreferencesSection>
<PreferencesSection title='Playback'> <PreferencesSection title='Playback'>
{this.openExternalPlayerCheckbox()} {this.openExternalPlayerCheckbox()}
{this.externalPlayerPathSelector()} {this.externalPlayerPathSelector()}
{this.highestPlaybackPriorityCheckbox()}
</PreferencesSection> </PreferencesSection>
<PreferencesSection title='Default torrent app'> <PreferencesSection title='Default torrent app'>
{this.setDefaultAppButton()} {this.setDefaultAppButton()}
</PreferencesSection> </PreferencesSection>
<PreferencesSection title='General'> {this.setStartupSection()}
{this.setStartupCheckbox()}
{this.soundNotificationsCheckbox()}
</PreferencesSection>
</div> </div>
) )
} }
@@ -264,7 +164,7 @@ export default class PreferencesPage extends React.Component {
class PreferencesSection extends React.Component { class PreferencesSection extends React.Component {
static get propTypes () { static get propTypes () {
return { return {
title: PropTypes.string title: React.PropTypes.string
} }
} }
@@ -288,3 +188,5 @@ class Preference extends React.Component {
return (<div style={style}>{this.props.children}</div>) return (<div style={style}>{this.props.children}</div>)
} }
} }
module.exports = PreferencesPage

View File

@@ -1,17 +1,14 @@
import * as React from 'react' const React = require('react')
import prettyBytes from 'prettier-bytes' const prettyBytes = require('prettier-bytes')
import checkbox from 'material-ui/Checkbox'
import linearProgress from 'material-ui/LinearProgress'
import TorrentSummary from '../lib/torrent-summary.js' const Checkbox = require('material-ui/Checkbox').default
import TorrentPlayer from '../lib/torrent-player.js' const LinearProgress = require('material-ui/LinearProgress').default
import { dispatcher } from '../lib/dispatcher.js'
import { calculateEta } from '../lib/time.js'
const Checkbox = checkbox.default const TorrentSummary = require('../lib/torrent-summary')
const LinearProgress = linearProgress.default const TorrentPlayer = require('../lib/torrent-player')
const {dispatcher} = require('../lib/dispatcher')
export default class TorrentList extends React.Component { module.exports = class TorrentList extends React.Component {
render () { render () {
const state = this.props.state const state = this.props.state
@@ -38,11 +35,7 @@ export default class TorrentList extends React.Component {
) )
return ( return (
<div <div key='torrent-list' className='torrent-list'>
key='torrent-list'
className='torrent-list'
onContextMenu={dispatcher('openTorrentListContextMenu')}
>
{contents} {contents}
</div> </div>
) )
@@ -74,8 +67,7 @@ export default class TorrentList extends React.Component {
style={style} style={style}
className={classes.join(' ')} className={classes.join(' ')}
onContextMenu={infoHash && dispatcher('openTorrentContextMenu', infoHash)} onContextMenu={infoHash && dispatcher('openTorrentContextMenu', infoHash)}
onClick={infoHash && dispatcher('toggleSelectTorrent', infoHash)} onClick={infoHash && dispatcher('toggleSelectTorrent', infoHash)}>
>
{this.renderTorrentMetadata(torrentSummary)} {this.renderTorrentMetadata(torrentSummary)}
{infoHash ? this.renderTorrentButtons(torrentSummary) : null} {infoHash ? this.renderTorrentButtons(torrentSummary) : null}
{isSelected ? this.renderTorrentDetails(torrentSummary) : null} {isSelected ? this.renderTorrentDetails(torrentSummary) : null}
@@ -138,8 +130,7 @@ export default class TorrentList extends React.Component {
}} }}
checked={isActive} checked={isActive}
onClick={stopPropagation} onClick={stopPropagation}
onCheck={dispatcher('toggleTorrent', infoHash)} onCheck={dispatcher('toggleTorrent', infoHash)} />
/>
) )
} }
@@ -156,7 +147,7 @@ export default class TorrentList extends React.Component {
} }
} }
return ( return (
<div key='progress-bar' style={styles.wrapper}> <div style={styles.wrapper}>
<LinearProgress style={styles.progress} mode='determinate' value={progress} /> <LinearProgress style={styles.progress} mode='determinate' value={progress} />
</div> </div>
) )
@@ -198,9 +189,18 @@ export default class TorrentList extends React.Component {
const downloadSpeed = prog.downloadSpeed const downloadSpeed = prog.downloadSpeed
if (downloadSpeed === 0 || missing === 0) return if (downloadSpeed === 0 || missing === 0) return
const etaStr = calculateEta(missing, downloadSpeed) const rawEta = missing / downloadSpeed
const hours = Math.floor(rawEta / 3600) % 24
const minutes = Math.floor(rawEta / 60) % 60
const seconds = Math.floor(rawEta % 60)
return (<span key='eta'>{etaStr}</span>) // 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>{hoursStr} {minutesStr} {secondsStr} remaining</span>)
} }
function renderTorrentStatus () { function renderTorrentStatus () {
@@ -210,15 +210,13 @@ export default class TorrentList extends React.Component {
else if (torrentSummary.progress.progress === 1) status = 'Not seeding' else if (torrentSummary.progress.progress === 1) status = 'Not seeding'
else status = 'Paused' else status = 'Paused'
} else if (torrentSummary.status === 'downloading') { } else if (torrentSummary.status === 'downloading') {
if (!torrentSummary.progress) status = '' status = 'Downloading'
else if (!torrentSummary.progress.ready) status = 'Verifying'
else status = 'Downloading'
} else if (torrentSummary.status === 'seeding') { } else if (torrentSummary.status === 'seeding') {
status = 'Seeding' status = 'Seeding'
} else { // torrentSummary.status is 'new' or something unexpected } else { // torrentSummary.status is 'new' or something unexpected
status = '' status = ''
} }
return (<span key='torrent-status'>{status}</span>) return (<span>{status}</span>)
} }
} }
@@ -234,9 +232,8 @@ export default class TorrentList extends React.Component {
<i <i
key='play-button' key='play-button'
title='Start streaming' title='Start streaming'
className='icon play' className={'icon play'}
onClick={dispatcher('playFile', infoHash)} onClick={dispatcher('playFile', infoHash)}>
>
play_circle_outline play_circle_outline
</i> </i>
) )
@@ -249,8 +246,7 @@ export default class TorrentList extends React.Component {
key='delete-button' key='delete-button'
className='icon delete' className='icon delete'
title='Remove torrent' title='Remove torrent'
onClick={dispatcher('confirmDeleteTorrent', infoHash, false)} onClick={dispatcher('confirmDeleteTorrent', infoHash, false)}>
>
close close
</i> </i>
</div> </div>
@@ -282,17 +278,10 @@ export default class TorrentList extends React.Component {
) )
} else { } else {
// We do know the files. List them and show download stats for each one // We do know the files. List them and show download stats for each one
const sortByName = this.props.state.saved.prefs.sortByName const fileRows = torrentSummary.files
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
let fileRows = torrentSummary.files
.filter((file) => !file.path.includes('/.____padding_file/')) .filter((file) => !file.path.includes('/.____padding_file/'))
.map((file, index) => ({ file, index })) .map((file, index) => ({ file, index }))
.map((object) => this.renderFileRow(torrentSummary, object.file, object.index))
if (sortByName) {
fileRows = fileRows.sort((a, b) => collator.compare(a.file.name, b.file.name))
}
fileRows = fileRows.map((obj) => this.renderFileRow(torrentSummary, obj.file, obj.index))
filesElement = ( filesElement = (
<div key='files' className='files'> <div key='files' className='files'>
@@ -323,7 +312,7 @@ export default class TorrentList extends React.Component {
torrentSummary.progress.files[index]) { torrentSummary.progress.files[index]) {
const fileProg = torrentSummary.progress.files[index] const fileProg = torrentSummary.progress.files[index]
isDone = fileProg.numPiecesPresent === fileProg.numPieces isDone = fileProg.numPiecesPresent === fileProg.numPieces
progress = Math.floor(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%' progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
} }
// Second, for media files where we saved our position, show how far we got // Second, for media files where we saved our position, show how far we got
@@ -344,7 +333,7 @@ export default class TorrentList extends React.Component {
} else { } else {
icon = 'description' /* file icon, opens in OS default app */ icon = 'description' /* file icon, opens in OS default app */
handleClick = isDone handleClick = isDone
? dispatcher('openPath', infoHash, index) ? dispatcher('openItem', infoHash, index)
: (e) => e.stopPropagation() // noop if file is not ready : (e) => e.stopPropagation() // noop if file is not ready
} }
// TODO: add a css 'disabled' class to indicate that a file cannot be opened/streamed // TODO: add a css 'disabled' class to indicate that a file cannot be opened/streamed
@@ -366,10 +355,8 @@ export default class TorrentList extends React.Component {
<td className={'col-size ' + rowClass}> <td className={'col-size ' + rowClass}>
{prettyBytes(file.length)} {prettyBytes(file.length)}
</td> </td>
<td <td className='col-select'
className='col-select' onClick={dispatcher('toggleTorrentFile', infoHash, index)}>
onClick={dispatcher('toggleTorrentFile', infoHash, index)}
>
<i className='icon deselect-file'>{isSelected ? 'close' : 'add'}</i> <i className='icon deselect-file'>{isSelected ? 'close' : 'add'}</i>
</td> </td>
</tr> </tr>
@@ -378,21 +365,21 @@ export default class TorrentList extends React.Component {
renderRadialProgressBar (fraction, cssClass) { renderRadialProgressBar (fraction, cssClass) {
const rotation = 360 * fraction const rotation = 360 * fraction
const transformFill = { transform: 'rotate(' + (rotation / 2) + 'deg)' } const transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
const transformFix = { transform: 'rotate(' + rotation + 'deg)' } const transformFix = {transform: 'rotate(' + rotation + 'deg)'}
return ( return (
<div key='radial-progress' className={'radial-progress ' + cssClass}> <div key='radial-progress' className={'radial-progress ' + cssClass}>
<div className='circle'> <div key='circle' className='circle'>
<div className='mask full' style={transformFill}> <div key='mask-full' className='mask full' style={transformFill}>
<div className='fill' style={transformFill} /> <div key='fill' className='fill' style={transformFill} />
</div> </div>
<div className='mask half'> <div key='mask-half' className='mask half'>
<div className='fill' style={transformFill} /> <div key='fill' className='fill' style={transformFill} />
<div className='fill fix' style={transformFix} /> <div key='fill-fix' className='fill fix' style={transformFix} />
</div> </div>
</div> </div>
<div className='inset' /> <div key='inset' className='inset' />
</div> </div>
) )
} }
@@ -406,7 +393,7 @@ function getErrorMessage (torrentSummary) {
const err = torrentSummary.error const err = torrentSummary.error
if (err === 'path-missing') { if (err === 'path-missing') {
return ( return (
<span key='path-missing'> <span>
Path missing.<br /> Path missing.<br />
Fix and restart the app, or delete the torrent. Fix and restart the app, or delete the torrent.
</span> </span>

View File

@@ -1,33 +1,39 @@
/* eslint-disable import/first */
// To keep the UI snappy, we run WebTorrent in its own hidden window, a separate // To keep the UI snappy, we run WebTorrent in its own hidden window, a separate
// process from the main window. // process from the main window.
console.time('init') console.time('init')
import crypto from 'crypto' const crypto = require('crypto')
import util from 'util' const deepEqual = require('deep-equal')
import { announceList as defaultAnnounceList } from 'create-torrent' const defaultAnnounceList = require('create-torrent').announceList
import electron from 'electron' const electron = require('electron')
import fs from 'fs' const fs = require('fs')
import * as mm from 'music-metadata' const mkdirp = require('mkdirp')
import networkAddress from 'network-address' const musicmetadata = require('musicmetadata')
import path from 'path' const networkAddress = require('network-address')
import WebTorrent from 'webtorrent' const path = require('path')
const WebTorrent = require('webtorrent')
const zeroFill = require('zero-fill')
import config from '../config.js' const crashReporter = require('../crash-reporter')
import { TorrentKeyNotFoundError } from './lib/errors.js' const config = require('../config')
import torrentPoster from './lib/torrent-poster.js' const {TorrentKeyNotFoundError} = require('./lib/errors')
const torrentPoster = require('./lib/torrent-poster')
const { ipcRenderer } = electron // Report when the process crashes
crashReporter.init()
// Send & receive messages from the main window
const ipc = electron.ipcRenderer
// Force use of webtorrent trackers on all torrents // Force use of webtorrent trackers on all torrents
globalThis.WEBTORRENT_ANNOUNCE = defaultAnnounceList global.WEBTORRENT_ANNOUNCE = defaultAnnounceList
.map((arr) => arr[0]) .map((arr) => arr[0])
.filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0) .filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0)
/** /**
* WebTorrent version. * WebTorrent version.
*/ */
const VERSION = JSON.parse(fs.readFileSync('package.json').toString()).version const VERSION = require('../../package.json').version
/** /**
* Version number in Azureus-style. Generated from major and minor semver version. * Version number in Azureus-style. Generated from major and minor semver version.
@@ -35,9 +41,10 @@ const VERSION = JSON.parse(fs.readFileSync('package.json').toString()).version
* '0.16.1' -> '0016' * '0.16.1' -> '0016'
* '1.2.5' -> '0102' * '1.2.5' -> '0102'
*/ */
const VERSION_STR = VERSION const VERSION_STR = VERSION.match(/([0-9]+)/g)
.replace(/\d*./g, v => `0${v % 100}`.slice(-2)) .slice(0, 2)
.slice(0, 4) .map((v) => zeroFill(2, v))
.join('')
/** /**
* Version prefix string (used in peer ID). WebTorrent uses the Azureus-style * Version prefix string (used in peer ID). WebTorrent uses the Azureus-style
@@ -68,38 +75,38 @@ init()
function init () { function init () {
listenToClientEvents() listenToClientEvents()
ipcRenderer.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) => ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) =>
startTorrenting(torrentKey, torrentID, path, fileModtimes, selections)) startTorrenting(torrentKey, torrentID, path, fileModtimes, selections))
ipcRenderer.on('wt-stop-torrenting', (e, infoHash) => ipc.on('wt-stop-torrenting', (e, infoHash) =>
stopTorrenting(infoHash)) stopTorrenting(infoHash))
ipcRenderer.on('wt-create-torrent', (e, torrentKey, options) => ipc.on('wt-create-torrent', (e, torrentKey, options) =>
createTorrent(torrentKey, options)) createTorrent(torrentKey, options))
ipcRenderer.on('wt-save-torrent-file', (e, torrentKey) => ipc.on('wt-save-torrent-file', (e, torrentKey) =>
saveTorrentFile(torrentKey)) saveTorrentFile(torrentKey))
ipcRenderer.on('wt-generate-torrent-poster', (e, torrentKey) => ipc.on('wt-generate-torrent-poster', (e, torrentKey) =>
generateTorrentPoster(torrentKey)) generateTorrentPoster(torrentKey))
ipcRenderer.on('wt-get-audio-metadata', (e, infoHash, index) => ipc.on('wt-get-audio-metadata', (e, infoHash, index) =>
getAudioMetadata(infoHash, index)) getAudioMetadata(infoHash, index))
ipcRenderer.on('wt-start-server', (e, infoHash) => ipc.on('wt-start-server', (e, infoHash) =>
startServer(infoHash)) startServer(infoHash))
ipcRenderer.on('wt-stop-server', () => ipc.on('wt-stop-server', (e) =>
stopServer()) stopServer())
ipcRenderer.on('wt-select-files', (e, infoHash, selections) => ipc.on('wt-select-files', (e, infoHash, selections) =>
selectFiles(infoHash, selections)) selectFiles(infoHash, selections))
ipcRenderer.send('ipcReadyWebTorrent') ipc.send('ipcReadyWebTorrent')
window.addEventListener('error', (e) => window.addEventListener('error', (e) =>
ipcRenderer.send('wt-uncaught-error', { message: e.error.message, stack: e.error.stack }), ipc.send('wt-uncaught-error', {message: e.error.message, stack: e.error.stack}),
true) true)
setInterval(updateTorrentProgress, 1000) setInterval(updateTorrentProgress, 1000)
console.timeEnd('init') console.timeEnd('init')
} }
function listenToClientEvents () { function listenToClientEvents () {
client.on('warning', (err) => ipcRenderer.send('wt-warning', null, err.message)) client.on('warning', (err) => ipc.send('wt-warning', null, err.message))
client.on('error', (err) => ipcRenderer.send('wt-error', null, err.message)) client.on('error', (err) => ipc.send('wt-error', null, err.message))
} }
// Starts a given TorrentID, which can be an infohash, magnet URI, etc. // Starts a given TorrentID, which can be an infohash, magnet URI, etc.
@@ -108,8 +115,8 @@ function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections)
console.log('starting torrent %s: %s', torrentKey, torrentID) console.log('starting torrent %s: %s', torrentKey, torrentID)
const torrent = client.add(torrentID, { const torrent = client.add(torrentID, {
path, path: path,
fileModtimes fileModtimes: fileModtimes
}) })
torrent.key = torrentKey torrent.key = torrentKey
@@ -121,7 +128,6 @@ function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections)
} }
function stopTorrenting (infoHash) { function stopTorrenting (infoHash) {
console.log('--- STOP TORRENTING: ', infoHash)
const torrent = client.get(infoHash) const torrent = client.get(infoHash)
if (torrent) torrent.destroy() if (torrent) torrent.destroy()
} }
@@ -133,44 +139,44 @@ function createTorrent (torrentKey, options) {
const torrent = client.seed(paths, options) const torrent = client.seed(paths, options)
torrent.key = torrentKey torrent.key = torrentKey
addTorrentEvents(torrent) addTorrentEvents(torrent)
ipcRenderer.send('wt-new-torrent') ipc.send('wt-new-torrent')
} }
function addTorrentEvents (torrent) { function addTorrentEvents (torrent) {
torrent.on('warning', (err) => torrent.on('warning', (err) =>
ipcRenderer.send('wt-warning', torrent.key, err.message)) ipc.send('wt-warning', torrent.key, err.message))
torrent.on('error', (err) => torrent.on('error', (err) =>
ipcRenderer.send('wt-error', torrent.key, err.message)) ipc.send('wt-error', torrent.key, err.message))
torrent.on('infoHash', () => torrent.on('infoHash', () =>
ipcRenderer.send('wt-parsed', torrent.key, torrent.infoHash, torrent.magnetURI)) ipc.send('wt-infohash', torrent.key, torrent.infoHash))
torrent.on('metadata', torrentMetadata) torrent.on('metadata', torrentMetadata)
torrent.on('ready', torrentReady) torrent.on('ready', torrentReady)
torrent.on('done', torrentDone) torrent.on('done', torrentDone)
function torrentMetadata () { function torrentMetadata () {
const info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipcRenderer.send('wt-metadata', torrent.key, info) ipc.send('wt-metadata', torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
} }
function torrentReady () { function torrentReady () {
const info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipcRenderer.send('wt-ready', torrent.key, info) ipc.send('wt-ready', torrent.key, info)
ipcRenderer.send('wt-ready-' + torrent.infoHash, torrent.key, info) ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
} }
function torrentDone () { function torrentDone () {
const info = getTorrentInfo(torrent) const info = getTorrentInfo(torrent)
ipcRenderer.send('wt-done', torrent.key, info) ipc.send('wt-done', torrent.key, info)
updateTorrentProgress() updateTorrentProgress()
torrent.getFileModtimes((err, fileModtimes) => { torrent.getFileModtimes(function (err, fileModtimes) {
if (err) return onError(err) if (err) return onError(err)
ipcRenderer.send('wt-file-modtimes', torrent.key, fileModtimes) ipc.send('wt-file-modtimes', torrent.key, fileModtimes)
}) })
} }
} }
@@ -203,19 +209,19 @@ function saveTorrentFile (torrentKey) {
const torrent = getTorrent(torrentKey) const torrent = getTorrent(torrentKey)
const torrentPath = path.join(config.TORRENT_PATH, torrent.infoHash + '.torrent') const torrentPath = path.join(config.TORRENT_PATH, torrent.infoHash + '.torrent')
fs.access(torrentPath, fs.constants.R_OK, err => { fs.access(torrentPath, fs.constants.R_OK, function (err) {
const fileName = torrent.infoHash + '.torrent' const fileName = torrent.infoHash + '.torrent'
if (!err) { if (!err) {
// We've already saved the file // We've already saved the file
return ipcRenderer.send('wt-file-saved', torrentKey, fileName) return ipc.send('wt-file-saved', torrentKey, fileName)
} }
// Otherwise, save the .torrent file, under the app config folder // Otherwise, save the .torrent file, under the app config folder
fs.mkdir(config.TORRENT_PATH, { recursive: true }, _ => { mkdirp(config.TORRENT_PATH, function (_) {
fs.writeFile(torrentPath, torrent.torrentFile, err => { fs.writeFile(torrentPath, torrent.torrentFile, function (err) {
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err) if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
console.log('saved torrent file %s', torrentPath) console.log('saved torrent file %s', torrentPath)
return ipcRenderer.send('wt-file-saved', torrentKey, fileName) return ipc.send('wt-file-saved', torrentKey, fileName)
}) })
}) })
}) })
@@ -225,17 +231,17 @@ function saveTorrentFile (torrentKey) {
// Auto chooses either a frame from a video file, an image, etc // Auto chooses either a frame from a video file, an image, etc
function generateTorrentPoster (torrentKey) { function generateTorrentPoster (torrentKey) {
const torrent = getTorrent(torrentKey) const torrent = getTorrent(torrentKey)
torrentPoster(torrent, (err, buf, extension) => { torrentPoster(torrent, function (err, buf, extension) {
if (err) return console.log('error generating poster: %o', err) if (err) return console.log('error generating poster: %o', err)
// save it for next time // save it for next time
fs.mkdir(config.POSTER_PATH, { recursive: true }, err => { mkdirp(config.POSTER_PATH, function (err) {
if (err) return console.log('error creating poster dir: %o', err) if (err) return console.log('error creating poster dir: %o', err)
const posterFileName = torrent.infoHash + extension const posterFileName = torrent.infoHash + extension
const posterFilePath = path.join(config.POSTER_PATH, posterFileName) const posterFilePath = path.join(config.POSTER_PATH, posterFileName)
fs.writeFile(posterFilePath, buf, err => { fs.writeFile(posterFilePath, buf, function (err) {
if (err) return console.log('error saving poster: %o', err) if (err) return console.log('error saving poster: %o', err)
// show the poster // show the poster
ipcRenderer.send('wt-poster', torrentKey, posterFileName) ipc.send('wt-poster', torrentKey, posterFileName)
}) })
}) })
}) })
@@ -244,23 +250,25 @@ function generateTorrentPoster (torrentKey) {
function updateTorrentProgress () { function updateTorrentProgress () {
const progress = getTorrentProgress() const progress = getTorrentProgress()
// TODO: diff torrent-by-torrent, not once for the whole update // TODO: diff torrent-by-torrent, not once for the whole update
if (prevProgress && util.isDeepStrictEqual(progress, prevProgress)) { if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) {
return /* don't send heavy object if it hasn't changed */ return /* don't send heavy object if it hasn't changed */
} }
ipcRenderer.send('wt-progress', progress) ipc.send('wt-progress', progress)
prevProgress = progress prevProgress = progress
} }
function getTorrentProgress () { function getTorrentProgress () {
// First, track overall progress // First, track overall progress
const progress = client.progress const progress = client.progress
const hasActiveTorrents = client.torrents.some(torrent => torrent.progress !== 1) const hasActiveTorrents = client.torrents.some(function (torrent) {
return torrent.progress !== 1
})
// Track progress for every file in each torrent // Track progress for every file in each torrent
// TODO: ideally this would be tracked by WebTorrent, which could do it // TODO: ideally this would be tracked by WebTorrent, which could do it
// more efficiently than looping over torrent.bitfield // more efficiently than looping over torrent.bitfield
const torrentProg = client.torrents.map(torrent => { const torrentProg = client.torrents.map(function (torrent) {
const fileProg = torrent.files && torrent.files.map(file => { const fileProg = torrent.files && torrent.files.map(function (file, index) {
const numPieces = file._endPiece - file._startPiece + 1 const numPieces = file._endPiece - file._startPiece + 1
let numPiecesPresent = 0 let numPiecesPresent = 0
for (let piece = file._startPiece; piece <= file._endPiece; piece++) { for (let piece = file._startPiece; piece <= file._endPiece; piece++) {
@@ -300,23 +308,22 @@ function startServer (infoHash) {
else torrent.once('ready', () => startServerFromReadyTorrent(torrent)) else torrent.once('ready', () => startServerFromReadyTorrent(torrent))
} }
function startServerFromReadyTorrent (torrent) { function startServerFromReadyTorrent (torrent, cb) {
if (server) return if (server) return
// start the streaming torrent-to-http server // start the streaming torrent-to-http server
server = torrent.createServer() server = torrent.createServer()
server.listen(0, () => { server.listen(0, function () {
const port = server.address().port const port = server.address().port
const urlSuffix = ':' + port const urlSuffix = ':' + port
const info = { const info = {
torrentKey: torrent.key, torrentKey: torrent.key,
localURL: 'http://localhost' + urlSuffix, localURL: 'http://localhost' + urlSuffix,
networkURL: 'http://' + networkAddress() + urlSuffix, networkURL: 'http://' + networkAddress() + urlSuffix
networkAddress: networkAddress()
} }
ipcRenderer.send('wt-server-running', info) ipc.send('wt-server-running', info)
ipcRenderer.send('wt-server-' + torrent.infoHash, info) ipc.send('wt-server-' + torrent.infoHash, info)
}) })
} }
@@ -326,46 +333,16 @@ function stopServer () {
server = null server = null
} }
console.log('Initializing...')
function getAudioMetadata (infoHash, index) { function getAudioMetadata (infoHash, index) {
const torrent = client.get(infoHash) const torrent = client.get(infoHash)
const file = torrent.files[index] const file = torrent.files[index]
musicmetadata(file.createReadStream(), function (err, info) {
// Set initial matadata to display the filename first. if (err) return console.log('error getting audio metadata for ' + infoHash + ':' + index, err)
const metadata = { title: file.name } const { artist, album, albumartist, title, year, track, disk, genre } = info
ipcRenderer.send('wt-audio-metadata', infoHash, index, metadata) const importantInfo = { artist, album, albumartist, title, year, track, disk, genre }
console.log('got audio metadata for %s: %o', file.name, importantInfo)
const options = { ipc.send('wt-audio-metadata', infoHash, index, importantInfo)
native: false, })
skipCovers: true,
fileSize: file.length,
observer: () => {
ipcRenderer.send('wt-audio-metadata', infoHash, index, {
common: metadata.common,
format: metadata.format
})
}
}
const onMetadata = file.done
// If completed; use direct file access
? mm.parseFile(path.join(torrent.path, file.path), options)
// otherwise stream
: mm.parseStream(file.createReadStream(), file.name, options)
onMetadata
.then(
metadata => {
ipcRenderer.send('wt-audio-metadata', infoHash, index, metadata)
console.log(`metadata for file='${file.name}' completed.`)
},
err => {
console.log(
`error getting audio metadata for ${infoHash}:${index}`,
err
)
}
)
} }
function selectFiles (torrentOrInfoHash, selections) { function selectFiles (torrentOrInfoHash, selections) {
@@ -385,7 +362,7 @@ function selectFiles (torrentOrInfoHash, selections) {
// selection with individual selections for each file, so we can // selection with individual selections for each file, so we can
// select/deselect files later on // select/deselect files later on
if (!selections) { if (!selections) {
selections = new Array(torrent.files.length).fill(true) selections = torrent.files.map((x) => true)
} }
// Selections specified incorrectly? // Selections specified incorrectly?
@@ -398,15 +375,15 @@ function selectFiles (torrentOrInfoHash, selections) {
torrent.deselect(0, torrent.pieces.length - 1, false) torrent.deselect(0, torrent.pieces.length - 1, false)
// Add selections (individual files) // Add selections (individual files)
selections.forEach((selection, i) => { for (let i = 0; i < selections.length; i++) {
const file = torrent.files[i] const file = torrent.files[i]
if (selection) { if (selections[i]) {
file.select() file.select()
} else { } else {
console.log('deselecting file ' + i + ' of torrent ' + torrent.name) console.log('deselecting file ' + i + ' of torrent ' + torrent.name)
file.deselect() file.deselect()
} }
}) }
} }
// Gets a WebTorrent handle by torrentKey // Gets a WebTorrent handle by torrentKey
@@ -424,7 +401,7 @@ function onError (err) {
// TODO: remove this once the following bugs are fixed: // TODO: remove this once the following bugs are fixed:
// https://bugs.chromium.org/p/chromium/issues/detail?id=490143 // https://bugs.chromium.org/p/chromium/issues/detail?id=490143
// https://github.com/electron/electron/issues/7212 // https://github.com/electron/electron/issues/7212
window.testOfflineMode = () => { window.testOfflineMode = function () {
console.log('Test, going OFFLINE') console.log('Test, going OFFLINE')
client = window.client = new WebTorrent({ client = window.client = new WebTorrent({
peerId: PEER_ID, peerId: PEER_ID,

View File

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

Binary file not shown.

Binary file not shown.

4
static/linux/postinst Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
chmod +x /opt/webtorrent-desktop/WebTorrent
ln -s -f /opt/webtorrent-desktop/WebTorrent /usr/bin/webtorrent-desktop

3
static/linux/prerm Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
set -e
rm /usr/bin/webtorrent-desktop

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