Compare commits

..

1 Commits

Author SHA1 Message Date
Mathias Rasmussen
6656b75c1b WIP: add embedded mkv subtitles 2016-08-30 00:28:37 +02:00
199 changed files with 4469 additions and 32742 deletions

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

@@ -0,0 +1,9 @@
**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'

7
.gitignore vendored
View File

@@ -1,9 +1,4 @@
node_modules/ node_modules/
build/ build/
dist/ dist/
npm-debug.log* npm-debug.log.*
.vscode/
# JetBrains IntelliJ IDEA project files
.idea
*.iml

2
.nodemonignore Normal file
View File

@@ -0,0 +1,2 @@
build/
dist/

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

View File

@@ -28,56 +28,5 @@
- Rémi Jouannet (remijouannet@gmail.com) - Rémi Jouannet (remijouannet@gmail.com)
- Andrea Tupini (tupini07@gmail.com) - Andrea Tupini (tupini07@gmail.com)
- grunjol (grunjol@gmail.com) - grunjol (grunjol@gmail.com)
- Jason Kurian (jasonk92@gmail.com)
- Vamsi Krishna Avula (vamsi_ism@outlook.com)
- Noam Okman (noamokman@gmail.com)
- PurgingPanda (t3ch0wn3r@gmail.com)
- Kai Curtis (morecode@kcurtis.com)
- Omri Litov (omrilitov@gmail.com)
- Alexey Romanov (romanalexey@gmail.com)
- Karan Thakkar (karanjthakkar@gmail.com)
- Nuno Campos (nuno.campos@me.com)
- 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,261 +1,18 @@
# WebTorrent Desktop Version History # WebTorrent Desktop Version History
## v0.24.0 - 2020-08-28
### 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
- Add a new "Transfers" menu for pausing or resuming all torrents (#1027)
### Changed
- Update Electron to 1.4.15
- Windows 32-bit: App can use 4GB of memory instead of just 2GB
- Fix "Portable App" writing crash reports to "%APPDATA%\Temp" (Windows)
- Updated WebTorrent engine to 0.98.5
- Fix issue where http web seeds would sometimes stall
- Don't send 'completed' event to tracker again if torrent is already complete
- Add more peer ID entropy
- Set user-agent header for tracker http requests
### Fixed
- Fix paste shortcut in tracker list on Create Torrent page (#1112)
- Auto-focus the 'OK' button in modal dialogs (#1058)
- Fix formatting issue in the speed stats on the Player page (#1039)
## v0.17.2 - 2016-10-10
### Fixed
- Windows: Fix impossible-to-delete "Wired CD" default torrent
- Throttle browser-window 'move' and 'resize' events
- Fix crash ("Cannot read property 'files' of null" error)
- Fix crash ("TypeError: Cannot read property 'startPiece' of undefined")
## v0.17.1 - 2016-10-03
### Changed
- Faster startup (improved by ~25%)
- Update Electron to 1.4.2
- Remove support for pasting multiple newline-separated magnet links
- Reduce UX sound volume
### Fixed
- Fix external player (VLC, etc.) opening before HTTP server was ready
- Windows (Portable App): Fix "Portable App" mode
- Write application support files to the "Portable Settings" folder
- Stop writing Electron "single instance" lock file to "%APPDATA%\Roaming\WebTorrent"
- Some temp data is still written to "%APPDATA%\Temp" (will be fixed in future version)
- Don't show pointer cursor on torrent list checkbox
- Trim extra whitespace from magnet links pasted into "Open Torrent Address" dialog
- Fix weird outline on 'Create Torrent' button
## v0.17.0 - 2016-09-23
### Added
- Remember window size and position
### Changed
- Torrent list redesign
- Quieter, more subtle sounds
- Got rid of the play button spinner, now goes to the player immediately
- Faster startup
### Fixed
- Fix bug where playback rate could go negative
- Don't hide header when moused over player controls
- Fix Delete Data File on Windows
- Fix a sad, sad bug that resulted in 100+ MB config files
- Fix app DMG background image
## v0.16.0 - 2016-09-18
### Added
- **Windows 64-bit support!** (#931)
- Existing 32-bit users will update to 64-bit automatically in next release
- 64-bit reduces likelihood of out-of-memory errors by increasing the address space
### Fixed
- Mac: Fix background image on .DMG
## v0.15.0 - 2016-09-16
### Added
- Option to start automatically on login
- Add integration tests
- Add more detailed telemetry to diagnose "buffer allocation failed"
### Changed
- Disable playback controls while in external player (#909)
### Fixed
- Fix several uncaught errors (#889, #891, #892)
- Update to the latest webtorrent.js, fixing some more uncaught errors
- Clicking on the "torrent finished" notification works again (#912)
## v0.14.0 - 2016-09-03
### Added
- Autoplay through all files in a torrent (#871)
- Torrents now have a progress bar (#844)
### Changed
- Modals now use Material UI
- Torrent list style improvements
### Fixed
- Fix App.js crash in Linux (#882)
- Fix error on Windows caused by `setBadge` (#867)
- Don't crash when restarting after adding a magnet link (#869)
- Restore playback state when reopening player (#877)
## v0.13.1 - 2016-08-31
### Fixed
- Fixed the Create Torrent page
## v0.13.0 - 2016-08-31
### Added
- Support .m4a audio
- Better telemetry: log error versions, report more types of errors
### Changed
- New look - Material UI. Rewrote Create Torrent and Preferences pages.
### Fixed
- Fixed telemetry [object Object] and [object HTMLMediaElement] bugs
- Don't render player controls when playing externally, eg in VLC
- Don't play notification sounds during media playback
## v0.12.0 - 2016-08-23 ## v0.12.0 - 2016-08-23
### Added ### Added
- Custom external media player - Custom external media player
- Linux: add system-wide launcher and icons for Debian, including Ubuntu - Linux: add system-wide launcher and icons for Debian, including Ubuntu
### Changed ### Changed
- Telemetry improvements: redact stacktraces, log app version - Telemetry improvements: redact stacktraces, log app version
### Fixed ### Fixed
- Fix playback and download of default torrents ("missing path" error) (#804) - Fix playback and download of default torrents ("missing path" error) (#804)
- Fix Delete Torrent + Data for newly added magnet links - Fix Delete Torrent + Data for newly added magnet links
- Fix jumpToTime error (#804) - Fix jumpToTime error (#804)
@@ -268,11 +25,13 @@
- Check for missing default download path and torrent folders on start up (#776) - Check for missing default download path and torrent folders on start up (#776)
### Changed ### Changed
- Do not automatically set WebTorrent as the default handler for torrents (#771) - Do not automatically set WebTorrent as the default handler for torrents (#771)
- Torrents can only be created from the home screen (#770) - Torrents can only be created from the home screen (#770)
- Update Electron to 1.3.3 (#772) - Update Electron to 1.3.3 (#772)
### Fixed ### Fixed
- Allow modifying the default tracker list on the Create Torrent page (#775) - Allow modifying the default tracker list on the Create Torrent page (#775)
- Prevent opening multiple stacked Preference windows or Create Torrent windows (#770) - Prevent opening multiple stacked Preference windows or Create Torrent windows (#770)
- Windows: Player window auto-resize does not match video aspect ratio (#565) - Windows: Player window auto-resize does not match video aspect ratio (#565)
@@ -281,11 +40,13 @@
## v0.10.0 - 2016-08-05 ## v0.10.0 - 2016-08-05
### Added ### Added
- Drag-and-drop magnet links (selected text) is now supported (#284) - Drag-and-drop magnet links (selected text) is now supported (#284)
- Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114) - Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114)
- Linux: Show badge count for completed torrent downloads - Linux: Show badge count for completed torrent downloads
### Changed ### Changed
- Change WebTorrent Desktop peer ID prefix to 'WD' to distinguish from WebTorrent in the browser, 'WW' (#688) - Change WebTorrent Desktop peer ID prefix to 'WD' to distinguish from WebTorrent in the browser, 'WW' (#688)
- Switch UI to React to improve UI rendering speed (#729) - Switch UI to React to improve UI rendering speed (#729)
- The primary bottleneck was actually `hyperx`, not `virtual-dom`. - The primary bottleneck was actually `hyperx`, not `virtual-dom`.
@@ -302,6 +63,7 @@
- Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history) - Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history)
### Fixed ### Fixed
- When streaming to VLC, set VLC window title to torrent file name (#746) - When streaming to VLC, set VLC window title to torrent file name (#746)
- Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695) - Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695)
- Fix rare case where config file could not be completely written (#733) - Fix rare case where config file could not be completely written (#733)
@@ -309,29 +71,35 @@
## v0.9.0 - 2016-07-20 ## v0.9.0 - 2016-07-20
### Added ### Added
- Save selected subtitles - Save selected subtitles
- Ask for confirmation before deleting torrents - Ask for confirmation before deleting torrents
- Support Debian Jessie - Support Debian Jessie
### Changed ### Changed
- Only send telemetry in production - Only send telemetry in production
- Clean up the code. Split main.js, refactor lots of things - Clean up the code. Split main.js, refactor lots of things
### Fixed ### Fixed
- Fix state.playing.jumpToTime behavior - Fix state.playing.jumpToTime behavior
- Remove torrent file and poster image when deleting a torrent - Remove torrent file and poster image when deleting a torrent
## v0.8.1 - 2016-06-24 ## v0.8.1 - 2016-06-24
### Added ### Added
- New URI handler: stream-magnet - New URI handler: stream-magnet
### Fixed ### Fixed
- DLNA crashing bug - DLNA crashing bug
## v0.8.0 - 2016-06-23 ## v0.8.0 - 2016-06-23
### Added ### Added
- Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use - Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use
- Telemetry: send basic data, plus stats on how often the play button works - Telemetry: send basic data, plus stats on how often the play button works
- Make posters from jpeg files, not just jpg - Make posters from jpeg files, not just jpg
@@ -339,14 +107,17 @@
- Windows thumbnail bar with a play/pause button - Windows thumbnail bar with a play/pause button
### Changed ### Changed
- Nicer modal styles - Nicer modal styles
### Fixed ### Fixed
- Windows tray icon now stays in the right state - Windows tray icon now stays in the right state
## v0.7.2 - 2016-06-02 ## v0.7.2 - 2016-06-02
### Fixed ### Fixed
- Fix exception that affects users upgrading from v0.5.1 or older - Fix exception that affects users upgrading from v0.5.1 or older
- Ensure `state.saved.prefs` configuration exists - Ensure `state.saved.prefs` configuration exists
- Fix window title on "About WebTorrent" window - Fix window title on "About WebTorrent" window
@@ -354,19 +125,23 @@
## v0.7.1 - 2016-06-02 ## v0.7.1 - 2016-06-02
### Changed ### Changed
- Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows) - Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows)
- Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows) - Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows)
### Fixed ### Fixed
- First time startup bug -- invalid torrent/poster paths - First time startup bug -- invalid torrent/poster paths
## v0.7.0 - 2016-06-02 ## v0.7.0 - 2016-06-02
### Added ### Added
- Improved AirPlay support -- using the new [`airplayer`](https://www.npmjs.com/package/airplayer) package - Improved AirPlay support -- using the new [`airplayer`](https://www.npmjs.com/package/airplayer) package
- Remember volume setting in player, for as long as the app is open - Remember volume setting in player, for as long as the app is open
### Changed ### Changed
- Add (+) button now also accepts non .torrent files and creates a torrent from - Add (+) button now also accepts non .torrent files and creates a torrent from
those files those files
- Show prompt text in title bar for open dialogs (OS X) - Show prompt text in title bar for open dialogs (OS X)
@@ -376,6 +151,7 @@
- Fix crash reporter not working (Windows) - Fix crash reporter not working (Windows)
### Fixed ### Fixed
- Re-enable WebRTC (web peers)! (OS X, Windows) - Re-enable WebRTC (web peers)! (OS X, Windows)
- Windows support was disabled in v0.6.1 to work around a bug in Electron - Windows support was disabled in v0.6.1 to work around a bug in Electron
- OS X support was disabled in v0.4.0 to work around a 100% CPU bug - OS X support was disabled in v0.4.0 to work around a 100% CPU bug
@@ -386,6 +162,7 @@
- Fix torrent loading message UI misalignment - Fix torrent loading message UI misalignment
### Known issues ### Known issues
- When upgrading to WebTorrent Desktop v0.7.0, some torrent metadata (file list, - When upgrading to WebTorrent Desktop v0.7.0, some torrent metadata (file list,
selected files, whether torrent is streamable) will be cleared. Just start the selected files, whether torrent is streamable) will be cleared. Just start the
torrent to re-populate the metadata. torrent to re-populate the metadata.
@@ -393,6 +170,7 @@
## v0.6.1 - 2016-05-26 ## v0.6.1 - 2016-05-26
### Fixed ### Fixed
- Disable WebRTC to work around Electron crash (Windows) - Disable WebRTC to work around Electron crash (Windows)
- Will be re-enabled in the next version of WebTorrent, which will be based on - Will be re-enabled in the next version of WebTorrent, which will be based on
the next version of Electron, where the bug is fixed. the next version of Electron, where the bug is fixed.
@@ -404,6 +182,7 @@
## v0.6.0 - 2016-05-24 ## v0.6.0 - 2016-05-24
### Added ### Added
- Added Preferences page to set Download folder - Added Preferences page to set Download folder
- Save video position, resume playback from saved position - Save video position, resume playback from saved position
- Add additional video player keyboard shortcuts (#275) - Add additional video player keyboard shortcuts (#275)
@@ -413,6 +192,7 @@
- Add announcement feature - Add announcement feature
### Changed ### Changed
- Nicer player UI - Nicer player UI
- Reduce startup jank, improve startup time (#568) - Reduce startup jank, improve startup time (#568)
- Cleanup unsupported codec detection (#569, #570) - Cleanup unsupported codec detection (#569, #570)
@@ -420,6 +200,7 @@
- Improve subtitle positioning (#551) - Improve subtitle positioning (#551)
### Fixed ### Fixed
- Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567) - Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
- Fix bugs in LocationHistory - Fix bugs in LocationHistory
- When player is active, and magnet link is pasted, go back to list - When player is active, and magnet link is pasted, go back to list
@@ -430,19 +211,23 @@
## v0.5.1 - 2016-05-18 ## v0.5.1 - 2016-05-18
### Fixed ### Fixed
- Fix auto-updater (OS X, Windows). - Fix auto-updater (OS X, Windows).
## v0.5.0 - 2016-05-17 ## v0.5.0 - 2016-05-17
### Added ### Added
- Select/deselect individual files to torrent. - Select/deselect individual files to torrent.
- Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu. - Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu.
- "Add Subtitle File..." menu item. - "Add Subtitle File..." menu item.
### Changed ### Changed
- When manually adding subtitle track(s), always switch to the new track. - When manually adding subtitle track(s), always switch to the new track.
### Fixed ### Fixed
- Magnet links throw exception on app launch. (OS X) - Magnet links throw exception on app launch. (OS X)
- Multi-file torrents would not seed in-place, were copied to Downloads folder. - Multi-file torrents would not seed in-place, were copied to Downloads folder.
- Missing 'About WebTorrent' menu item. (Windows) - Missing 'About WebTorrent' menu item. (Windows)
@@ -451,6 +236,7 @@
## v0.4.0 - 2016-05-13 ## v0.4.0 - 2016-05-13
### Added ### Added
- Better Windows support! - Better Windows support!
- Windows 32-bit build. - Windows 32-bit build.
- Windows Portable App build. - Windows Portable App build.
@@ -473,6 +259,7 @@
- New default torrent on first launch: The WIRED CD. - New default torrent on first launch: The WIRED CD.
### Changed ### Changed
- Improve app startup time by 40%. - Improve app startup time by 40%.
- UI tweaks: Reduce font size, reduce torrent list item height. - UI tweaks: Reduce font size, reduce torrent list item height.
- Add Playback menu for playback-related functionality. - Add Playback menu for playback-related functionality.
@@ -483,6 +270,7 @@
- Remove "Add Fake Airplay/Chromecast" menu items. - Remove "Add Fake Airplay/Chromecast" menu items.
### Fixed ### Fixed
- Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is - Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is
temporary. (OS X) temporary. (OS X)
- When fullscreen, make controls use the full window. (OS X) - When fullscreen, make controls use the full window. (OS X)
@@ -507,20 +295,24 @@ to this release!
## v0.3.3 - 2016-04-07 ## v0.3.3 - 2016-04-07
### Fixed ### Fixed
- App icon was incorrect (OS X) - App icon was incorrect (OS X)
## v0.3.2 - 2016-04-07 ## v0.3.2 - 2016-04-07
### Added ### Added
- Register WebTorrent as default handler for magnet links (OS X) - Register WebTorrent as default handler for magnet links (OS X)
### Changed ### Changed
- Faster startup time (50ms) - Faster startup time (50ms)
- Update Electron to 0.37.5 - Update Electron to 0.37.5
- Remove the white flash when loading pages and resizing the window - Remove the white flash when loading pages and resizing the window
- Fix crash when sending IPC messages - Fix crash when sending IPC messages
### Fixed ### Fixed
- Fix installation bugs with .deb file (Linux) - Fix installation bugs with .deb file (Linux)
- Pause audio reliably when closing the window - Pause audio reliably when closing the window
- Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0) - Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0)
@@ -528,14 +320,17 @@ to this release!
## v0.3.1 - 2016-04-06 ## v0.3.1 - 2016-04-06
### Added ### Added
- Add crash reporter to torrent engine process - Add crash reporter to torrent engine process
### Fixed ### Fixed
- Fix cast screen background: cover, don't tile - Fix cast screen background: cover, don't tile
## v0.3.0 - 2016-04-06 ## v0.3.0 - 2016-04-06
### Added ### Added
- **Ubuntu/Debian support!** (.deb installer) - **Ubuntu/Debian support!** (.deb installer)
- **DLNA streaming support** - **DLNA streaming support**
- Add "File > Quit" menu item (Linux) - Add "File > Quit" menu item (Linux)
@@ -543,12 +338,14 @@ to this release!
- Crash reporting - Crash reporting
### Changed ### Changed
- On startup, do not re-verify files when timestamps are unchanged - On startup, do not re-verify files when timestamps are unchanged
- Moved torrent engine to an independent process, for better UI performance - Moved torrent engine to an independent process, for better UI performance
- Removed media queries (UI resizing based on window width) - Removed media queries (UI resizing based on window width)
- Improved Chromecast icon, when connected - Improved Chromecast icon, when connected
### Fixed ### Fixed
- "Download Complete" notification shows consistently - "Download Complete" notification shows consistently
- Create new torrents and seed them without copying to temporary folder - Create new torrents and seed them without copying to temporary folder
- Clicking the "Download Complete" notification will always activate app - Clicking the "Download Complete" notification will always activate app
@@ -567,6 +364,7 @@ Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
## v0.2.0 - 2016-03-29 ## v0.2.0 - 2016-03-29
### Added ### Added
- Minimise to tray (Windows, Linux) - Minimise to tray (Windows, Linux)
- Show spinner and download speed when player is stalled waiting for data - Show spinner and download speed when player is stalled waiting for data
- Highlight window on drag-and-drop - Highlight window on drag-and-drop
@@ -575,10 +373,12 @@ Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
Linux users need to download new versions manually. Linux users need to download new versions manually.
### Changed ### Changed
- Renamed WebTorrent.app to WebTorrent Desktop - Renamed WebTorrent.app to WebTorrent Desktop
- Add Cosmos Laundromat as a default torrent - Add Cosmos Laundromat as a default torrent
### Fixed ### Fixed
- Only capture media keys when player is active - Only capture media keys when player is active
- Update WebTorrent to 0.88.1 for performance improvements - Update WebTorrent to 0.88.1 for performance improvements
- When seeding, do not proactively connect to new peers - When seeding, do not proactively connect to new peers
@@ -638,7 +438,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
``` ```
@@ -72,38 +56,6 @@ Restart the app automatically every time code changes. Useful during development
$ npm run watch $ npm run watch
``` ```
### Run linters
```
$ npm test
```
### Run integration tests
```
$ npm run test-integration
```
The integration tests use Spectron and Tape. They click through the app, taking screenshots and
comparing each one to a reference. Why screenshots?
* Ad-hoc checking makes the tests a lot more work to write
* Even diffing the whole HTML is not as thorough as screenshot diffing. For example, it wouldn't
catch an bug where hitting ESC from a video doesn't correctly restore window size.
* Chrome's own integration tests use screenshot diffing iirc
* Small UI changes will break a few tests, but the fix is as easy as deleting the offending
screenshots and running the tests, which will recreate them with the new look.
* The resulting Github PR will then show, pixel by pixel, the exact UI changes that were made! See
https://github.com/blog/817-behold-image-view-modes
For MacOS, you'll need a Retina screen for the integration tests to pass. Your screen should have
the same resolution as a 2018 MacBook Pro 13".
For Windows, you'll need Windows 10 with a 1366x768 screen.
When running integration tests, keep the mouse on the edge of the screen and don't touch the mouse
or keyboard while the tests are running.
### Package the app ### Package the app
Builds app binaries for Mac, Linux, and Windows. Builds app binaries for Mac, Linux, and Windows.
@@ -125,7 +77,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 +84,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 +93,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 +110,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 +119,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.**

108
bin/check-deps.js Executable file
View File

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

View File

@@ -5,13 +5,13 @@
* Useful for developers. * Useful for developers.
*/ */
const fs = require('fs') var fs = require('fs')
const os = require('os') var os = require('os')
const path = require('path') var path = require('path')
const rimraf = require('rimraf') var rimraf = require('rimraf')
const config = require('../src/config') var config = require('../src/config')
const handlers = require('../src/main/handlers') var handlers = require('../src/main/handlers')
// First, remove generated files // First, remove generated files
rimraf.sync('build/') rimraf.sync('build/')
@@ -21,11 +21,11 @@ rimraf.sync('dist/')
rimraf.sync(config.CONFIG_PATH) rimraf.sync(config.CONFIG_PATH)
// Remove any temporary files // Remove any temporary files
let tmpPath var 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)

10
bin/cmd.js Executable file
View File

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

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>

10
bin/list-deps.sh Executable file
View File

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

View File

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

View File

@@ -1,28 +1,28 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Builds app binaries for Mac, Windows, and Linux. * Builds app binaries for Mac, Linux, and Windows.
*/ */
const cp = require('child_process') var cp = require('child_process')
const electronPackager = require('electron-packager') var electronPackager = require('electron-packager')
const fs = require('fs') var fs = require('fs')
const minimist = require('minimist') var minimist = require('minimist')
const os = require('os') var mkdirp = require('mkdirp')
const path = require('path') var os = require('os')
const rimraf = require('rimraf') var path = require('path')
const series = require('run-series') var rimraf = require('rimraf')
const zip = require('cross-zip') var series = require('run-series')
var zip = require('cross-zip')
const config = require('../src/config') var config = require('../src/config')
const pkg = require('../package.json') var pkg = require('../package.json')
const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION var BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
const BUILD_PATH = path.join(config.ROOT_PATH, 'build') var BUILD_PATH = path.join(config.ROOT_PATH, 'build')
const DIST_PATH = path.join(config.ROOT_PATH, 'dist') var 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), { var argv = minimist(process.argv.slice(2), {
boolean: [ boolean: [
'sign' 'sign'
], ],
@@ -36,19 +36,14 @@ 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)
console.log('Build: Transpiling to ES5...') console.log('Babel: Building JSX...')
cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' }) cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' })
console.log('Build: Transpiled to ES5.') console.log('Babel: Built JSX.')
const platform = argv._[0] var platform = argv._[0]
if (platform === 'darwin') { if (platform === 'darwin') {
buildDarwin(printDone) buildDarwin(printDone)
} else if (platform === 'win32') { } else if (platform === 'win32') {
@@ -66,35 +61,35 @@ function build () {
} }
} }
const all = { var 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
// require(). // require().
asar: { asar: true,
// A glob expression, that unpacks the files with matching names to the
// "app.asar.unpacked" directory. // A glob expression, that unpacks the files with matching names to the
unpack: 'WebTorrent*' // "app.asar.unpacked" directory.
}, 'asar-unpack': 'WebTorrent*',
// The build version of the application. Maps to the FileVersion metadata property on // The build version of the application. Maps to the FileVersion metadata property on
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to // Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
// 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,
// Pattern which specifies which files to ignore when copying files to create the // Pattern which specifies which files to ignore when copying files to create the
// package(s). // package(s).
ignore: /^\/src|^\/dist|\/(appveyor.yml|\.appveyor.yml|\.github|appdmg|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^/]*|.*\.md|.*\.markdown)$/, ignore: /^\/src|^\/dist|\/(appveyor.yml|\.appveyor.yml|\.github|appdmg|AUTHORS|CONTRIBUTORS|bench|benchmark|benchmark\.js|bin|bower\.json|component\.json|coverage|doc|docs|docs\.mli|dragdrop\.min\.js|example|examples|example\.html|example\.js|externs|ipaddr\.min\.js|Makefile|min|minimist|perf|rusha|simplepeer\.min\.js|simplewebsocket\.min\.js|static\/screenshot\.png|test|tests|test\.js|tests\.js|webtorrent\.min\.js|\.[^\/]*|.*\.md|.*\.markdown)$/,
// The application name. // The application name.
name: config.APP_NAME, name: config.APP_NAME,
@@ -109,40 +104,40 @@ const all = {
// "devDependencies" before starting to package the app. // "devDependencies" before starting to package the app.
prune: true, prune: true,
// The Electron version that the app is built with (without the leading 'v') // The Electron version with which the app is built (without the leading 'v')
electronVersion: require('electron/package.json').version version: require('electron/package.json').version
} }
const darwin = { var darwin = {
// Build for Mac // Build for Mac
platform: 'darwin', platform: 'darwin',
// Build x64 binary only. // Build 64 bit 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'
} }
const win32 = { var win32 = {
// Build for Windows. // Build for Windows.
platform: 'win32', platform: 'win32',
// Build x64 binary only. // Build 32 bit binaries only.
arch: 'x64', arch: 'ia32',
// 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: { 'version-string': {
// Company that produced the file. // Company that produced the file.
CompanyName: config.APP_NAME, CompanyName: config.APP_NAME,
@@ -168,12 +163,12 @@ const win32 = {
icon: config.APP_ICON + '.ico' icon: config.APP_ICON + '.ico'
} }
const linux = { var linux = {
// Build for Linux. // Build for Linux.
platform: 'linux', platform: 'linux',
// Build x64, armv7l, and arm64 binaries. // Build 32 and 64 bit binaries.
arch: ['x64', 'armv7l', 'arm64'] arch: 'all'
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option. // Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
} }
@@ -181,30 +176,31 @@ const linux = {
build() build()
function buildDarwin (cb) { function buildDarwin (cb) {
const plist = require('plist') var 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') var appPath = path.join(buildPath[0], config.APP_NAME + '.app')
const contentsPath = path.join(appPath, 'Contents') var contentsPath = path.join(appPath, 'Contents')
const resourcesPath = path.join(contentsPath, 'Resources') var resourcesPath = path.join(contentsPath, 'Resources')
const infoPlistPath = path.join(contentsPath, 'Info.plist') var infoPlistPath = path.join(contentsPath, 'Info.plist')
const infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8')) var infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
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'
} }
} }
@@ -265,8 +261,7 @@ function buildDarwin (cb) {
} }
function signApp (cb) { function signApp (cb) {
const sign = require('electron-osx-sign') var 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:
@@ -281,38 +276,17 @@ function buildDarwin (cb) {
* - Xcode Command Line Tools (xcode-select --install) * - Xcode Command Line Tools (xcode-select --install)
* - Membership in the Apple Developer Program * - Membership in the Apple Developer Program
*/ */
const signOpts = { var 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)
})
}) })
} }
@@ -328,8 +302,8 @@ function buildDarwin (cb) {
// Create .zip file (used by the auto-updater) // Create .zip file (used by the auto-updater)
console.log('Mac: Creating zip...') console.log('Mac: Creating zip...')
const inPath = path.join(buildPath[0], config.APP_NAME + '.app') var inPath = path.join(buildPath[0], config.APP_NAME + '.app')
const outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip') var outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log('Mac: Created zip.') console.log('Mac: Created zip.')
@@ -338,13 +312,13 @@ function buildDarwin (cb) {
function packageDmg (cb) { function packageDmg (cb) {
console.log('Mac: Creating dmg...') console.log('Mac: Creating dmg...')
const appDmg = require('appdmg') var appDmg = require('appdmg')
const targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg') var targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
rimraf.sync(targetPath) rimraf.sync(targetPath)
// Create a .dmg (Mac disk image) file, for easy user installation. // Create a .dmg (Mac disk image) file, for easy user installation.
const dmgOpts = { var dmgOpts = {
basepath: config.ROOT_PATH, basepath: config.ROOT_PATH,
target: targetPath, target: targetPath,
specification: { specification: {
@@ -365,7 +339,7 @@ function buildDarwin (cb) {
} }
} }
const dmg = appDmg(dmgOpts) var dmg = appDmg(dmgOpts)
dmg.once('error', cb) dmg.once('error', cb)
dmg.on('progress', function (info) { dmg.on('progress', function (info) {
if (info.type === 'step-begin') console.log(info.title + '...') if (info.type === 'step-begin') console.log(info.title + '...')
@@ -375,13 +349,11 @@ function buildDarwin (cb) {
cb(null) cb(null)
}) })
} }
}).catch(function (err) {
cb(err)
}) })
} }
function buildWin32 (cb) { function buildWin32 (cb) {
const installer = require('electron-winstaller') var installer = require('electron-winstaller')
console.log('Windows: Packaging electron...') console.log('Windows: Packaging electron...')
/* /*
@@ -389,7 +361,7 @@ function buildWin32 (cb) {
* - Windows Authenticode private key and cert (authenticode.p12) * - Windows Authenticode private key and cert (authenticode.p12)
* - Windows Authenticode password file (authenticode.txt) * - Windows Authenticode password file (authenticode.txt)
*/ */
let CERT_PATH var CERT_PATH
try { try {
fs.accessSync('D:') fs.accessSync('D:')
CERT_PATH = 'D:' CERT_PATH = 'D:'
@@ -397,15 +369,16 @@ 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 var signWithParams
if (process.platform === 'win32') { if (process.platform === 'win32') {
if (argv.sign) { if (argv.sign) {
const certificateFile = path.join(CERT_PATH, 'authenticode.p12') var certificateFile = path.join(CERT_PATH, 'authenticode.p12')
const certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8') var certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
const timestampServer = 'http://timestamp.comodoca.com' var timestampServer = 'http://timestamp.comodoca.com'
signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256` signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
} else { } else {
printWarning() printWarning()
@@ -414,22 +387,20 @@ function buildWin32 (cb) {
printWarning() printWarning()
} }
const tasks = [] var tasks = []
buildPath.forEach(function (filesPath) { if (argv.package === 'exe' || argv.package === 'all') {
if (argv.package === 'exe' || argv.package === 'all') { tasks.push((cb) => packageInstaller(cb))
tasks.push((cb) => packageInstaller(filesPath, cb)) }
} if (argv.package === 'portable' || argv.package === 'all') {
if (argv.package === 'portable' || argv.package === 'all') { tasks.push((cb) => packagePortable(cb))
tasks.push((cb) => packagePortable(filesPath, cb)) }
}
})
series(tasks, cb) series(tasks, cb)
function packageInstaller (filesPath, cb) { function packageInstaller (cb) {
console.log('Windows: Creating installer...') console.log('Windows: Creating installer...')
installer.createWindowsInstaller({ installer.createWindowsInstaller({
appDirectory: filesPath, appDirectory: buildPath[0],
authors: config.APP_TEAM, authors: config.APP_TEAM,
description: config.APP_NAME, description: config.APP_NAME,
exe: config.APP_NAME + '.exe', exe: config.APP_NAME + '.exe',
@@ -439,174 +410,98 @@ function buildWin32 (cb) {
noMsi: true, noMsi: true,
outputDirectory: DIST_PATH, outputDirectory: DIST_PATH,
productName: config.APP_NAME, productName: config.APP_NAME,
// TODO: Re-enable Windows 64-bit delta updates when we confirm that they remoteReleases: config.GITHUB_URL,
// work correctly in the presence of the "ia32" .nupkg files. I
// (feross) noticed them listed in the 64-bit RELEASES file and
// manually edited them out for the v0.17 release. Shipping only
// full updates for now will work fine, with no ill-effects.
// remoteReleases: config.GITHUB_URL,
/**
* If you hit a "GitHub API rate limit exceeded" error, set this token!
*/
// remoteToken: process.env.WEBTORRENT_GITHUB_API_TOKEN,
setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.exe', setupExe: config.APP_NAME + 'Setup-v' + config.APP_VERSION + '.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 installer.')
cb(null)
/** })
* Delete extraneous Squirrel files (i.e. *.nupkg delta files for older .catch(cb)
* versions of the app)
*/
fs.readdirSync(DIST_PATH)
.filter((name) => name.endsWith('.nupkg') && !name.includes(pkg.version))
.forEach((filename) => {
fs.unlinkSync(path.join(DIST_PATH, filename))
})
cb(null)
})
.catch(cb)
} }
function packagePortable (filesPath, cb) { function packagePortable (cb) {
console.log('Windows: Creating portable app...') console.log('Windows: Creating portable app...')
const portablePath = path.join(filesPath, 'Portable Settings') var portablePath = path.join(buildPath[0], 'Portable Settings')
fs.mkdirSync(portablePath, { recursive: true }) mkdirp.sync(portablePath)
const downloadsPath = path.join(portablePath, 'Downloads') var inPath = path.join(DIST_PATH, path.basename(buildPath[0]))
fs.mkdirSync(downloadsPath, { recursive: true }) var outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip')
const tempPath = path.join(portablePath, 'Temp')
fs.mkdirSync(tempPath, { recursive: true })
const inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, BUILD_NAME + '-win.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log('Windows: Created portable app.') console.log('Windows: Created 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 = [] var tasks = []
buildPath.forEach(function (filesPath) { buildPath.forEach(function (filesPath) {
const destArch = filesPath.split('-').pop() var destArch = filesPath.split('-').pop()
if (argv.package === 'deb' || argv.package === 'all') { if (argv.package === 'deb' || argv.package === 'all') {
tasks.push((cb) => packageDeb(filesPath, destArch, cb)) tasks.push((cb) => packageDeb(filesPath, destArch, cb))
} }
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') var deb = require('nobin-debian-installer')()
var 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 inPath = path.join(DIST_PATH, path.basename(filesPath)) var inPath = path.join(DIST_PATH, path.basename(filesPath))
const outPath = path.join(DIST_PATH, `${BUILD_NAME}-linux-${destArch}.zip`) var outPath = path.join(DIST_PATH, BUILD_NAME + '-linux-' + destArch + '.zip')
zip.zipSync(inPath, outPath) zip.zipSync(inPath, outPath)
console.log(`Linux: Created ${destArch} zip.`) console.log(`Linux: Created ${destArch} zip.`)

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

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

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,74 @@
{ {
"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.12.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": { "bin": {
"plugins": [ "webtorrent-desktop": "./bin/cmd.js"
[
"@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", "bitfield": "^1.0.2",
"arch": "2.2.0", "chromecasts": "^1.8.0",
"auto-launch": "5.0.5", "create-torrent": "^3.24.5",
"bitfield": "4.0.0", "deep-equal": "^1.0.1",
"capture-frame": "4.0.0", "dlnacasts": "^0.1.0",
"chokidar": "3.5.2", "drag-drop": "^2.12.1",
"chromecasts": "1.10.2", "electron": "1.3.3",
"create-torrent": "5.0.1", "fs-extra": "^0.30.0",
"debounce": "1.2.1", "iso-639-1": "^1.2.1",
"dlnacasts": "0.1.0", "languagedetect": "^1.1.1",
"drag-drop": "7.2.0", "location-history": "^1.0.0",
"es6-error": "4.1.1", "material-ui": "^0.15.4",
"fn-getter": "1.0.0", "matroska-subtitles": "^2.0.0",
"iso-639-1": "2.1.9", "musicmetadata": "^2.0.2",
"languagedetect": "2.0.0", "network-address": "^1.1.0",
"location-history": "1.1.2", "parse-torrent": "^5.7.3",
"material-ui": "0.20.2", "prettier-bytes": "^1.0.1",
"music-metadata": "7.11.4", "react": "^15.2.1",
"network-address": "1.1.2", "react-dom": "^15.2.1",
"parse-torrent": "9.1.4", "react-tap-event-plugin": "^1.0.0",
"prettier-bytes": "1.0.4", "run-parallel": "^1.1.6",
"prop-types": "15.7.2", "semver": "^5.1.0",
"react": "17.0.2", "simple-concat": "^1.0.0",
"react-dom": "17.0.2", "simple-get": "^2.0.0",
"rimraf": "3.0.2", "srt-to-vtt": "^1.1.1",
"run-parallel": "1.2.0", "vlc-command": "^1.0.1",
"semver": "7.3.5", "webtorrent": "0.x",
"simple-concat": "1.0.1", "winreg": "^1.2.0",
"simple-get": "4.0.0", "zero-fill": "^2.2.3"
"srt-to-vtt": "1.1.3",
"vlc-command": "1.2.0",
"webtorrent": "1.5.6",
"winreg": "1.2.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.15.7", "babel-cli": "^6.11.4",
"@babel/core": "7.15.8", "babel-plugin-syntax-jsx": "^6.13.0",
"@babel/eslint-parser": "7.15.8", "babel-plugin-transform-es2015-destructuring": "^6.9.0",
"@babel/plugin-transform-react-jsx": "7.14.9", "babel-plugin-transform-object-rest-spread": "^6.8.0",
"cross-zip": "4.0.0", "babel-plugin-transform-react-jsx": "^6.8.0",
"depcheck": "1.4.2", "cross-zip": "^2.0.1",
"electron": "15.2.0", "electron-osx-sign": "^0.3.0",
"electron-notarize": "1.1.1", "electron-packager": "^7.0.0",
"electron-osx-sign": "0.5.0", "electron-winstaller": "^2.3.0",
"electron-packager": "15.4.0", "gh-release": "^2.0.3",
"electron-winstaller": "5.0.0", "minimist": "^1.2.0",
"gh-release": "6.0.1", "mkdirp": "^0.5.1",
"minimist": "1.2.5", "nobin-debian-installer": "^0.0.10",
"nodemon": "2.0.13", "nodemon": "^1.10.2",
"open": "8.3.0", "open": "0.0.5",
"plist": "3.0.4", "plist": "^2.0.1",
"pngjs": "6.0.0", "rimraf": "^2.5.2",
"run-series": "1.1.9", "run-series": "^1.1.4",
"spectron": "15.0.0", "standard": "*"
"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 +82,24 @@
"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,
"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": "babel --quiet src --out-dir build",
"clean": "node ./bin/clean.js", "clean": "node ./bin/clean.js",
"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-integration": "npm run build && node ./test", "test": "standard && node ./bin/check-deps.js",
"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,pug,css"
},
"standard": {
"parser": "@babel/eslint-parser"
},
"renovate": {
"extends": [
"github>webtorrent/renovate-config"
]
} }
} }

8
src/.babelrc Normal file
View File

@@ -0,0 +1,8 @@
{
"plugins": [
"syntax-jsx",
"transform-es2015-destructuring",
"transform-object-rest-spread",
"transform-react-jsx"
]
}

View File

@@ -1,25 +1,85 @@
import applicationConfig from 'application-config' var appConfig = require('application-config')('WebTorrent')
import path from 'path' var fs = require('fs')
import arch from 'arch' var path = require('path')
import fs from 'fs'
import electron from '../electron.cjs'
const appConfig = applicationConfig('WebTorrent') var APP_NAME = 'WebTorrent'
const APP_NAME = 'WebTorrent' var APP_TEAM = 'WebTorrent, LLC'
const APP_TEAM = 'WebTorrent, LLC' var APP_VERSION = require('../package.json').version
const APP_VERSION = JSON.parse(fs.readFileSync('package.json').toString()).version
const IS_TEST = isTest()
const PORTABLE_PATH = IS_TEST
? path.join(process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp', 'WebTorrentTest')
: path.join(path.dirname(process.execPath), 'Portable Settings')
const IS_PRODUCTION = isProduction()
const IS_PORTABLE = isPortable()
const UI_HEADER_HEIGHT = 38 var PORTABLE_PATH = path.join(path.dirname(process.execPath), 'Portable Settings')
const UI_TORRENT_HEIGHT = 100
module.exports = {
ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement',
AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update',
CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report',
TELEMETRY_URL: 'https://webtorrent.io/desktop/telemetry',
APP_COPYRIGHT: 'Copyright © 2014-2016 ' + APP_TEAM,
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
APP_NAME: APP_NAME,
APP_TEAM: APP_TEAM,
APP_VERSION: APP_VERSION,
APP_WINDOW_TITLE: APP_NAME + ' (BETA)',
CONFIG_PATH: getConfigPath(),
DEFAULT_TORRENTS: [
{
name: 'Big Buck Bunny',
posterFileName: 'bigBuckBunny.jpg',
torrentFileName: 'bigBuckBunny.torrent'
},
{
name: 'Cosmos Laundromat (Preview)',
posterFileName: 'cosmosLaundromat.jpg',
torrentFileName: 'cosmosLaundromat.torrent'
},
{
name: 'Sintel',
posterFileName: 'sintel.jpg',
torrentFileName: 'sintel.torrent'
},
{
name: 'Tears of Steel',
posterFileName: 'tearsOfSteel.jpg',
torrentFileName: 'tearsOfSteel.torrent'
},
{
name: 'The WIRED CD - Rip. Sample. Mash. Share.',
posterFileName: 'wiredCd.jpg',
torrentFileName: 'wiredCd.torrent'
}
],
DELAYED_INIT: 3000 /* 3 seconds */,
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
GITHUB_URL: 'https://github.com/feross/webtorrent-desktop',
GITHUB_URL_ISSUES: 'https://github.com/feross/webtorrent-desktop/issues',
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/feross/webtorrent-desktop/master',
HOME_PAGE_URL: 'https://webtorrent.io',
IS_PORTABLE: isPortable(),
IS_PRODUCTION: isProduction(),
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
ROOT_PATH: 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_MIN_HEIGHT: 38 + (120 * 2), // header height + 2 torrents
WINDOW_MIN_WIDTH: 425
}
function getConfigPath () { function getConfigPath () {
if (IS_PORTABLE) { if (isPortable()) {
return PORTABLE_PATH return PORTABLE_PATH
} else { } else {
return path.dirname(appConfig.filePath) return path.dirname(appConfig.filePath)
@@ -27,41 +87,24 @@ function getConfigPath () {
} }
function getDefaultDownloadPath () { function getDefaultDownloadPath () {
if (IS_PORTABLE) { if (!process || !process.type) {
return path.join(getConfigPath(), 'Downloads')
} else {
return getPath('downloads')
}
}
function getPath (key) {
if (!process.versions.electron || process.type !== 'browser') {
// Node.js process
return '' return ''
} }
// Electron main process
return electron.app.getPath(key)
}
function isTest () { if (isPortable()) {
return process.env.NODE_ENV === 'test' return path.join(getConfigPath(), 'Downloads')
}
var electron = require('electron')
return process.type === 'renderer'
? electron.remote.app.getPath('downloads')
: electron.app.getPath('downloads')
} }
function isPortable () { function isPortable () {
if (IS_TEST) {
return true
}
if (process.platform !== 'win32' || !IS_PRODUCTION) {
// Fast path: Non-Windows platforms should not check for path on disk
return false
}
try { try {
// This line throws if the "Portable Settings" folder does not exist, and does return process.platform === 'win32' && isProduction() && !!fs.statSync(PORTABLE_PATH)
// nothing otherwise.
fs.accessSync(PORTABLE_PATH, fs.constants.R_OK | fs.constants.W_OK)
return true
} catch (err) { } catch (err) {
return false return false
} }
@@ -69,7 +112,6 @@ function isPortable () {
function isProduction () { function isProduction () {
if (!process.versions.electron) { if (!process.versions.electron) {
// Node.js process
return false return false
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
@@ -82,117 +124,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 } var config = require('./config')
var electron = require('electron')
function init () {
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 = var electron = require('electron')
`${config.ANNOUNCEMENT_URL}?version=${config.APP_VERSION}&platform=${process.platform}`
var config = require('../config')
var log = require('./log')
var 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') var 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,20 +1,32 @@
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 var electron = require('electron')
var log = require('./log')
var 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 = { var opts = {
title: 'Select a file for the torrent.', title: 'Select a file for the torrent.',
properties: ['openFile'] properties: [ 'openFile' ]
} }
showOpenSeed(opts) setTitle(opts.title)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
} }
/* /*
@@ -22,68 +34,75 @@ 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' var 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) setTitle(opts.title)
electron.dialog.showOpenDialog(windows.main.win, opts, function (selectedPaths) {
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
})
} }
/* /*
* 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' var 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 = { var 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')
} }
@@ -100,15 +119,3 @@ function setTitle (title) {
function resetTitle () { function resetTitle () {
windows.main.dispatch('resetTitle') windows.main.dispatch('resetTitle')
} }
/**
* Pops up an Open File dialog with the given options.
* After the user selects files / folders, shows the Create Torrent page.
*/
function showOpenSeed (opts) {
setTitle(opts.title)
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts)
resetTitle()
if (!Array.isArray(selectedPaths)) return
windows.main.dispatch('showCreateTorrent', selectedPaths)
}

View File

@@ -1,15 +1,22 @@
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 var electron = require('electron')
var app = electron.app
var dialog = require('./dialog')
var log = require('./log')
/** /**
* Add a right-click menu to the dock icon. (Mac) * Add a right-click menu to the dock icon. (Mac)
*/ */
function init () { function init () {
if (!app.dock) return if (!app.dock) return
const menu = Menu.buildFromTemplate(getMenuTemplate()) var menu = electron.Menu.buildFromTemplate(getMenuTemplate())
app.dock.setMenu(menu) app.dock.setMenu(menu)
} }
@@ -26,11 +33,8 @@ function downloadFinished (path) {
* Display a counter badge for the app. (Mac, Linux) * Display a counter badge for the app. (Mac, Linux)
*/ */
function setBadge (count) { function setBadge (count) {
if (process.platform === 'darwin' || log(`setBadge: ${count}`)
(process.platform === 'linux' && app.isUnityRunning())) { app.setBadgeCount(Number(count))
log(`setBadge: ${count}`)
app.badgeCount = Number(count)
}
} }
function getMenuTemplate () { function getMenuTemplate () {
@@ -52,5 +56,3 @@ function getMenuTemplate () {
} }
] ]
} }
export default { downloadFinished, init, setBadge }

View File

@@ -1,56 +1,53 @@
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'
// holds a ChildProcess while we're playing a video in an external player, null otherwise
let proc = null
export function checkInstall (playerPath, cb) {
// check for VLC if external player has not been specified by the user
// otherwise assume the player is installed
if (!playerPath) return vlcCommand(cb)
process.nextTick(() => cb(null))
} }
export function spawn (playerPath, url, title) { var cp = require('child_process')
if (playerPath) return spawnExternal(playerPath, [url]) var vlcCommand = require('vlc-command')
var log = require('./log')
var windows = require('./windows')
// holds a ChildProcess while we're playing a video in an external player, null otherwise
var proc
function checkInstall (path, cb) {
// check for VLC if external player has not been specified by the user
// otherwise assume the player is installed
if (path == null) return vlcCommand((err) => cb(!err))
process.nextTick(() => cb(true))
}
function spawn (path, url, title) {
if (path != null) return spawnExternal(path, [url])
// Try to find and use VLC if external player is not specified // 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 = [ var args = ['--play-and-exit', '--video-on-top', '--quiet', `--meta-title=${JSON.stringify(title)}`, url]
'--play-and-exit',
'--quiet',
`--meta-title=${JSON.stringify(title)}`,
url
]
spawnExternal(vlcPath, args) spawnExternal(vlcPath, args)
}) })
} }
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 (path, args) {
log('Running external media player:', `${playerPath} ${args.join(' ')}`) log('Running external media player:', path + ' ' + args.join(' '))
if (process.platform === 'darwin' && path.extname(playerPath) === '.app') { proc = cp.spawn(path, args, {stdio: 'ignore'})
// Mac: Use executable in packaged .app bundle
playerPath += `/Contents/MacOS/${path.basename(playerPath, '.app')}`
}
proc = cp.spawn(playerPath, args, { stdio: 'ignore' })
// If it works, close the modal after a second // If it works, close the modal after a second
const closeModalTimeout = setTimeout(() => var 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 +59,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 () { var config = require('../config')
switch (process.platform) { var 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') var electron = require('electron')
var 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, '--'] var 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') var Registry = require('winreg')
const log = await import('./log')
const iconPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'static', 'WebTorrentFile.ico') var log = require('./log')
registerProtocolHandlerWin32('magnet', 'URL:BitTorrent Magnet URL', iconPath, EXEC_COMMAND)
registerProtocolHandlerWin32('stream-magnet', 'URL:BitTorrent Stream-Magnet URL', iconPath, EXEC_COMMAND) var 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:
@@ -68,7 +100,7 @@ async function installWin32 () {
*/ */
function registerProtocolHandlerWin32 (protocol, name, icon, command) { function registerProtocolHandlerWin32 (protocol, name, icon, command) {
const protocolKey = new Registry({ var protocolKey = new Registry({
hive: Registry.HKCU, // HKEY_CURRENT_USER hive: Registry.HKCU, // HKEY_CURRENT_USER
key: '\\Software\\Classes\\' + protocol key: '\\Software\\Classes\\' + protocol
}) })
@@ -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({ var 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({ var 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)
} }
} }
@@ -129,51 +161,52 @@ async function installWin32 () {
setExt() setExt()
function setExt () { function setExt () {
const extKey = new Registry({ var 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({ var 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({ var 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({ var 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') var 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)
@@ -182,11 +215,11 @@ async function uninstallWin32 () {
getCommand() getCommand()
function getCommand () { function getCommand () {
const commandKey = new Registry({ var 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()
} }
@@ -194,11 +227,11 @@ async function uninstallWin32 () {
} }
function destroyProtocol () { function destroyProtocol () {
const protocolKey = new Registry({ var protocolKey = new Registry({
hive: Registry.HKCU, hive: Registry.HKCU,
key: `\\Software\\Classes\\${protocol}` key: '\\Software\\Classes\\' + protocol
}) })
protocolKey.destroy(() => {}) protocolKey.destroy(function () {})
} }
} }
@@ -206,19 +239,19 @@ async function uninstallWin32 () {
eraseId() eraseId()
function eraseId () { function eraseId () {
const idKey = new Registry({ var 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)
} }
function getExt () { function getExt () {
const extKey = new Registry({ var 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()
} }
@@ -226,11 +259,11 @@ async function uninstallWin32 () {
} }
function destroyExt () { function destroyExt () {
const extKey = new Registry({ var 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,95 @@ async function uninstallWin32 () {
function commandToArgs (command) { function commandToArgs (command) {
return command.map((arg) => `"${arg}"`).join(' ') return command.map((arg) => `"${arg}"`).join(' ')
} }
function installLinux () {
var fs = require('fs-extra')
var os = require('os')
var path = require('path')
var config = require('../config')
var log = require('./log')
// Do not install in user dir if running on system
if (/^\/opt/.test(process.execPath)) return
installDesktopFile()
installIconFile()
function installDesktopFile () {
var templatePath = path.join(
config.STATIC_PATH, 'linux', 'webtorrent-desktop.desktop'
)
fs.readFile(templatePath, 'utf8', writeDesktopFile)
}
function writeDesktopFile (err, desktopFile) {
if (err) return log.error(err.message)
var 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)
var 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 () {
var iconStaticPath = path.join(config.STATIC_PATH, 'WebTorrent.png')
fs.readFile(iconStaticPath, writeIconFile)
}
function writeIconFile (err, iconFile) {
if (err) return log.error(err.message)
var iconFilePath = path.join(
os.homedir(),
'.local',
'share',
'icons',
'webtorrent-desktop.png'
)
fs.mkdirp(path.dirname(iconFilePath))
fs.writeFile(iconFilePath, iconFile, function (err) {
if (err) return log.error(err.message)
})
}
}
function uninstallLinux () {
var os = require('os')
var path = require('path')
var fs = require('fs-extra')
var desktopFilePath = path.join(
os.homedir(),
'.local',
'share',
'applications',
'webtorrent-desktop.desktop'
)
fs.removeSync(desktopFilePath)
var iconFilePath = path.join(
os.homedir(),
'.local',
'share',
'icons',
'webtorrent-desktop.png'
)
fs.removeSync(iconFilePath)
}

View File

@@ -1,36 +1,26 @@
/* eslint-disable import/first */
console.time('init') console.time('init')
import * as RemoteMain from '@electron/remote/main/index.js' var electron = require('electron')
RemoteMain.initialize()
import electron from '../../electron.cjs'
const { app, ipcMain } = electron
// Start crash reporter early, so it takes effect for child processes var app = electron.app
import crashReporter from '../crash-reporter.js' var ipcMain = electron.ipcMain
crashReporter.init()
import fs from 'fs' var announcement = require('./announcement')
import parallel from 'run-parallel' var config = require('../config')
import config from '../config.js' var crashReporter = require('../crash-reporter')
import ipc from './ipc.js' var dialog = require('./dialog')
import log from './log.js' var dock = require('./dock')
import menu from './menu.js' var ipc = require('./ipc')
import State from '../renderer/lib/state.js' var log = require('./log')
import * as windows from './windows/index.js' var menu = require('./menu')
var squirrelWin32 = require('./squirrel-win32')
var tray = require('./tray')
var updater = require('./updater')
var userTasks = require('./user-tasks')
var windows = require('./windows')
const WEBTORRENT_VERSION = JSON.parse(fs.readFileSync('node_modules/webtorrent/package.json').toString()).version var shouldQuit = false
var argv = sliceArgv(process.argv)
let shouldQuit = false
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
// (On Windows and Linux, we get a flag. On MacOS, we get special API.)
const hidden = argv.includes('--hidden') ||
(process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAsHidden)
if (config.IS_PRODUCTION) { if (config.IS_PRODUCTION) {
// When Electron is running in production mode (packaged app), then run React // When Electron is running in production mode (packaged app), then run React
@@ -39,137 +29,90 @@ if (config.IS_PRODUCTION) {
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
const squirrelWin32 = require('./squirrel-win32')
shouldQuit = squirrelWin32.handleEvent(argv[0]) shouldQuit = squirrelWin32.handleEvent(argv[0])
argv = argv.filter((arg) => !arg.includes('--squirrel')) argv = argv.filter((arg) => !arg.includes('--squirrel'))
} }
if (!shouldQuit && !config.IS_PORTABLE) { if (!shouldQuit) {
// Prevent multiple instances of app from running at same time. New instances // Prevent multiple instances of app from running at same time. New instances
// signal this instance and quit. Note: This feature creates a lock file in // signal this instance and quit.
// %APPDATA%\Roaming\WebTorrent so we do not do it for the Portable App since shouldQuit = app.makeSingleInstance(onAppOpen)
// we want to be "silent" as well as "portable". if (shouldQuit) {
if (!app.requestSingleInstanceLock()) { app.quit()
shouldQuit = true
} }
} }
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 = await import('path')
// Put all user data into the "Portable Settings" folder
app.setPath('userData', config.CONFIG_PATH) app.setPath('userData', config.CONFIG_PATH)
// Put Electron crash files, etc. into the "Portable Settings\Temp" folder
app.setPath('temp', path.join(config.CONFIG_PATH, 'Temp'))
} }
let isReady = false // app ready, windows can be created var 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({ // Open handlers must be added as early as possible
appReady: (cb) => app.whenReady().then(cb),
state: (cb) => State.load(cb)
}, onReady)
function onReady (err, results) {
if (err) throw err
isReady = true
const state = results.state
menu.init()
windows.main.init(state, { hidden })
windows.webtorrent.init()
// To keep app startup fast, some code is delayed.
setTimeout(() => {
delayedInit(state)
}, config.DELAYED_INIT)
// Report uncaught exceptions
process.on('uncaughtException', (err) => {
console.error(err)
const error = { message: err.message, stack: err.stack }
windows.main.dispatch('uncaughtError', 'main', error)
})
}
// 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.on('ready', function () {
isReady = true
windows.main.init()
windows.webtorrent.init()
menu.init()
// To keep app startup fast, some code is delayed.
setTimeout(delayedInit, config.DELAYED_INIT)
// Report uncaught exceptions
process.on('uncaughtException', (err) => {
console.error(err)
var error = {message: err.message, stack: err.stack}
windows.main.dispatch('uncaughtError', 'main', error)
})
})
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
e.preventDefault() e.preventDefault()
windows.main.dispatch('stateSaveImmediate') // try to save state on exit windows.main.dispatch('saveState') // try to save state on exit
ipcMain.once('stateSaved', () => app.quit()) ipcMain.once('savedState', () => app.quit())
setTimeout(() => { setTimeout(() => {
console.error('Saving state took too long. Quitting.') console.error('Saving state took too long. Quitting.')
app.quit() app.quit()
}, 4000) // quit after 4 secs, at most }, 2000) // quit after 2 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
const { default: announcement } = await import('./announcement.js')
const { default: dock } = await import('./dock.js')
const { default: updater } = await import('./updater.js')
const { FolderWatcher } = await import('./folder-watcher.js')
const folderWatcher = new FolderWatcher({ window: windows.main, state })
announcement.init() announcement.init()
dock.init() dock.init()
tray.init()
updater.init() updater.init()
userTasks.init()
ipc.setModule('folderWatcher', folderWatcher)
if (folderWatcher.isEnabled()) {
folderWatcher.start()
}
if (process.platform === 'win32') {
const userTasks = await import('./user-tasks.js')
userTasks.init()
}
if (process.platform !== 'darwin') {
const { init: trayInit } = await import('./tray.js')
trayInit()
}
} }
function onOpen (e, torrentId) { function onOpen (e, torrentId) {
@@ -181,7 +124,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)
} }
@@ -200,49 +143,29 @@ function onAppOpen (newArgv) {
} }
} }
// Remove leading args.
// Production: 1 arg, eg: /Applications/WebTorrent.app/Contents/MacOS/WebTorrent
// Development: 2 args, eg: electron .
// Test: 4 args, eg: electron -r .../mocks.js .
function sliceArgv (argv) { function sliceArgv (argv) {
return argv.slice( return argv.slice(config.IS_PRODUCTION ? 1 : 2)
config.IS_PRODUCTION
? 1
: config.IS_TEST
? 4
: 2
)
} }
async function processArgv (argv) { function processArgv (argv) {
const torrentIds = [] var torrentIds = []
await Promise.all(argv.forEach(async arg => { argv.forEach(function (arg) {
if (arg === '-n' || arg === '-o' || arg === '-u') { if (arg === '-n') {
// Critical path: Only load the 'dialog' package if it is needed dialog.openSeedDirectory()
const dialog = await import('./dialog') } else if (arg === '-o') {
if (arg === '-n') { dialog.openTorrentFile()
dialog.openSeedDirectory() } else if (arg === '-u') {
} else if (arg === '-o') { dialog.openTorrentAddress()
dialog.openTorrentFile()
} else if (arg === '-u') {
dialog.openTorrentAddress()
}
} else if (arg === '--hidden') {
// Ignore hidden argument, already being handled
} else if (arg.startsWith('-psn')) { } else if (arg.startsWith('-psn')) {
// Ignore Mac launchd "process serial number" argument // Ignore Mac launchd "process serial number" argument
// Issue: https://github.com/webtorrent/webtorrent-desktop/issues/214 // Issue: https://github.com/feross/webtorrent-desktop/issues/214
} else if (arg.startsWith('--')) {
// Ignore Spectron flags
} else if (arg === 'data:,') {
// Ignore weird Spectron argument
} else if (arg !== '.') { } else if (arg !== '.') {
// Ignore '.' argument, which gets misinterpreted as a torrent id, when a // Ignore '.' argument, which gets misinterpreted as a torrent id, when a
// development copy of WebTorrent is started while a production version is // development copy of WebTorrent is started while a production version is
// 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,39 @@
import electron from '../../electron.cjs' module.exports = {
init
import log from './log.js'
import menu from './menu.js'
import * as windows from './windows/index.js'
const { app, ipcMain } = electron
// Messages from the main process, to be sent once the WebTorrent process starts
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
} }
var electron = require('electron')
var app = electron.app
var dialog = require('./dialog')
var dock = require('./dock')
var handlers = require('./handlers')
var log = require('./log')
var menu = require('./menu')
var powerSaveBlocker = require('./power-save-blocker')
var shell = require('./shell')
var shortcuts = require('./shortcuts')
var externalPlayer = require('./external-player')
var windows = require('./windows')
var thumbar = require('./thumbar')
// Messages from the main process, to be sent once the WebTorrent process starts
var messageQueueMainToWebTorrent = []
function init () { function init () {
ipcMain.once('ipcReady', e => { var 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,190 +43,93 @@ function init () {
* Dialog * Dialog
*/ */
ipcMain.on('openTorrentFile', async () => { ipc.on('openTorrentFile', () => dialog.openTorrentFile())
const dialog = await import('./dialog') ipc.on('openFiles', () => dialog.openFiles())
dialog.openTorrentFile()
})
ipcMain.on('openFiles', async () => {
const dialog = await import('./dialog')
dialog.openFiles()
})
/** /**
* Dock * Dock
*/ */
ipcMain.on('setBadge', async (e, ...args) => { ipc.on('setBadge', (e, ...args) => dock.setBadge(...args))
const dock = await import('./dock') ipc.on('downloadFinished', (e, ...args) => dock.downloadFinished(...args))
dock.setBadge(...args)
})
ipcMain.on('downloadFinished', async (e, ...args) => {
const dock = await import('./dock')
dock.downloadFinished(...args)
})
/** /**
* Player Events * Events
*/ */
ipcMain.on('onPlayerOpen', async () => { ipc.on('onPlayerOpen', function () {
const powerSaveBlocker = await import('./power-save-blocker') menu.setPlayerOpen(true)
const shortcuts = await import('./shortcuts')
const thumbar = await import('./thumbar')
menu.togglePlaybackControls(true)
powerSaveBlocker.enable() powerSaveBlocker.enable()
shortcuts.enable() shortcuts.enable()
thumbar.enable() thumbar.enable()
}) })
ipcMain.on('onPlayerUpdate', async (e, ...args) => { ipc.on('onPlayerClose', function () {
const thumbar = await import('./thumbar') menu.setPlayerOpen(false)
menu.onPlayerUpdate(...args)
thumbar.onPlayerUpdate(...args)
})
ipcMain.on('onPlayerClose', async () => {
const powerSaveBlocker = await import('./power-save-blocker')
const shortcuts = await import('./shortcuts')
const thumbar = await import('./thumbar')
menu.togglePlaybackControls(false)
powerSaveBlocker.disable() powerSaveBlocker.disable()
shortcuts.disable() shortcuts.disable()
thumbar.disable() thumbar.disable()
}) })
ipcMain.on('onPlayerPlay', async () => { ipc.on('onPlayerPlay', function () {
const powerSaveBlocker = await import('./power-save-blocker')
const thumbar = await import('./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 thumbar = await import('./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) => shell.openItem(...args))
const shell = await import('./shell') ipc.on('showItemInFolder', (e, ...args) => shell.showItemInFolder(...args))
shell.openPath(...args) ipc.on('moveItemToTrash', (e, ...args) => shell.moveItemToTrash(...args))
})
ipcMain.on('showItemInFolder', async (e, ...args) => {
const shell = await import('./shell')
shell.showItemInFolder(...args)
})
ipcMain.on('moveItemToTrash', async (e, ...args) => {
const shell = await import('./shell')
shell.moveItemToTrash(...args)
})
/** /**
* File handlers * File handlers
*/ */
ipc.on('setDefaultFileHandler', (e, flag) => {
ipcMain.on('setDefaultFileHandler', async (e, flag) => {
const handlers = await import('./handlers')
if (flag) handlers.install() if (flag) handlers.install()
else handlers.uninstall() else handlers.uninstall()
}) })
/**
* Auto start on login
*/
ipcMain.on('setStartup', async (e, flag) => {
const startup = await import('./startup')
if (flag) startup.install()
else startup.uninstall()
})
/** /**
* Windows: Main * Windows: Main
*/ */
const main = windows.main var 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') externalPlayer.checkInstall(path, function (isInstalled) {
windows.main.send('checkForExternalPlayer', isInstalled)
externalPlayer.checkInstall(path, err => {
windows.main.send('checkForExternalPlayer', !err)
}) })
}) })
ipcMain.on('openExternalPlayer', async (e, ...args) => { ipc.on('openExternalPlayer', (e, ...args) => externalPlayer.spawn(...args))
const externalPlayer = await import('./external-player') ipc.on('quitExternalPlayer', () => externalPlayer.kill())
const shortcuts = await import('./shortcuts')
const thumbar = await import('./thumbar')
menu.togglePlaybackControls(false) // Capture all events
shortcuts.disable() var oldEmit = ipc.emit
thumbar.disable() ipc.emit = function (name, e, ...args) {
externalPlayer.spawn(...args)
})
ipcMain.on('quitExternalPlayer', async () => {
const externalPlayer = await import('./external-player')
externalPlayer.kill()
})
/**
* Message passing
*/
const oldEmit = ipcMain.emit
ipcMain.emit = (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 +140,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 +149,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) {
var electron = require('electron')
var windows = require('./windows')
var 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,20 +1,30 @@
import electron from '../../electron.cjs' module.exports = {
import config from '../config.js' init,
import * as windows from './windows/index.js' setPlayerOpen,
import shell from './shell.js' setWindowFocus,
setAllowNav,
onToggleAlwaysOnTop,
onToggleFullScreen
}
let menu = null var electron = require('electron')
var app = electron.app
var config = require('../config')
var dialog = require('./dialog')
var shell = require('./shell')
var windows = require('./windows')
var menu
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)
} }
function togglePlaybackControls (flag) { function setPlayerOpen (flag) {
getMenuItem('Play/Pause').enabled = flag getMenuItem('Play/Pause').enabled = flag
getMenuItem('Skip Next').enabled = flag
getMenuItem('Skip Previous').enabled = flag
getMenuItem('Increase Volume').enabled = flag getMenuItem('Increase Volume').enabled = flag
getMenuItem('Decrease Volume').enabled = flag getMenuItem('Decrease Volume').enabled = flag
getMenuItem('Step Forward').enabled = flag getMenuItem('Step Forward').enabled = flag
@@ -22,16 +32,6 @@ function togglePlaybackControls (flag) {
getMenuItem('Increase Speed').enabled = flag getMenuItem('Increase Speed').enabled = flag
getMenuItem('Decrease Speed').enabled = flag getMenuItem('Decrease Speed').enabled = flag
getMenuItem('Add Subtitles File...').enabled = flag getMenuItem('Add Subtitles File...').enabled = flag
if (flag === false) {
getMenuItem('Skip Next').enabled = false
getMenuItem('Skip Previous').enabled = false
}
}
function onPlayerUpdate (hasNext, hasPrevious) {
getMenuItem('Skip Next').enabled = hasNext
getMenuItem('Skip Previous').enabled = hasPrevious
} }
function setWindowFocus (flag) { function setWindowFocus (flag) {
@@ -59,15 +59,16 @@ function onToggleFullScreen (flag) {
} }
function getMenuItem (label) { function getMenuItem (label) {
for (const menuItem of menu.items) { for (var i = 0; i < menu.items.length; i++) {
const submenuItem = menuItem.submenu.items.find(item => item.label === label) var 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 () {
const template = [ var template = [
{ {
label: 'File', label: 'File',
submenu: [ submenu: [
@@ -76,26 +77,17 @@ 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: () => dialog.openSeedDirectory()
const dialog = await import('./dialog')
dialog.openSeedDirectory()
}
}, },
{ {
label: 'Open Torrent File...', label: 'Open Torrent File...',
accelerator: 'CmdOrCtrl+O', accelerator: 'CmdOrCtrl+O',
click: async () => { click: () => dialog.openTorrentFile()
const dialog = await import('./dialog')
dialog.openTorrentFile()
}
}, },
{ {
label: 'Open Torrent Address...', label: 'Open Torrent Address...',
accelerator: 'CmdOrCtrl+U', accelerator: 'CmdOrCtrl+U',
click: async () => { click: () => dialog.openTorrentAddress()
const dialog = await import('./dialog')
dialog.openTorrentAddress()
}
}, },
{ {
type: 'separator' type: 'separator'
@@ -195,21 +187,6 @@ function getMenuTemplate () {
{ {
type: 'separator' type: 'separator'
}, },
{
label: 'Skip Next',
accelerator: 'N',
click: () => windows.main.dispatch('nextTrack'),
enabled: false
},
{
label: 'Skip Previous',
accelerator: 'P',
click: () => windows.main.dispatch('previousTrack'),
enabled: false
},
{
type: 'separator'
},
{ {
label: 'Increase Volume', label: 'Increase Volume',
accelerator: 'CmdOrCtrl+Up', accelerator: 'CmdOrCtrl+Up',
@@ -266,69 +243,31 @@ function getMenuTemplate () {
} }
] ]
}, },
{
label: 'Transfers',
submenu: [
{
label: 'Pause All',
click: () => windows.main.dispatch('pauseAllTorrents')
},
{
label: 'Resume All',
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)
}
]
},
{ {
label: 'Help', label: 'Help',
role: 'help', role: 'help',
submenu: [ submenu: [
{ {
label: 'Learn more about ' + config.APP_NAME, label: 'Learn more about ' + config.APP_NAME,
click: () => { click: () => 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: () => shell.openExternal(config.GITHUB_URL)
shell.openExternal(config.GITHUB_URL)
}
}, },
{ {
type: 'separator' type: 'separator'
}, },
{ {
label: 'Report an Issue...', label: 'Report an Issue...',
click: () => { click: () => 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 +286,8 @@ function getMenuTemplate () {
type: 'separator' type: 'separator'
}, },
{ {
role: 'services' role: 'services',
submenu: []
}, },
{ {
type: 'separator' type: 'separator'
@@ -370,26 +310,8 @@ function getMenuTemplate () {
] ]
}) })
// Edit menu (Mac) // Add Window menu (Mac)
template[2].submenu.push( template.splice(5, 0, {
{
type: 'separator'
},
{
label: 'Speech',
submenu: [
{
role: 'startspeaking'
},
{
role: 'stopspeaking'
}
]
}
)
// Window menu (Mac)
template.splice(6, 0, {
role: 'window', role: 'window',
submenu: [ submenu: [
{ {
@@ -411,10 +333,7 @@ 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: () => dialog.openSeedFile()
const dialog = await import('./dialog')
dialog.openSeedFile()
}
}) })
// Edit menu (Windows, Linux) // Edit menu (Windows, Linux)
@@ -429,7 +348,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 +364,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,29 +1,34 @@
import { powerSaveBlocker } from 'electron' module.exports = {
import log from './log.js' enable,
disable
}
let blockId = 0 var electron = require('electron')
var log = require('./log')
var 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 var electron = require('electron')
var 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,20 @@
import { globalShortcut } from 'electron' module.exports = {
import * as windows from './windows' disable,
enable
}
export function enable () { var electron = require('electron')
var 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(
'MediaNextTrack',
() => windows.main.dispatch('nextTrack')
)
globalShortcut.register(
'MediaPreviousTrack',
() => 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')
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 EXE_NAME = path.basename(process.execPath)
const UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe')
const run = (args, done) => {
spawn(UPDATE_EXE, args, { detached: true }).on('close', done)
} }
export function handleEvent (cmd) { var cp = require('child_process')
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated') { var electron = require('electron')
run([`--createShortcut=${EXE_NAME}`], app.quit) var fs = require('fs')
var os = require('os')
var path = require('path')
var app = electron.app
var handlers = require('./handlers')
var EXE_NAME = path.basename(process.execPath)
var UPDATE_EXE = path.join(process.execPath, '..', '..', 'Update.exe')
function handleEvent (cmd) {
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
}
if (cmd === '--squirrel-updated') {
// App was updated. (Called on new version of app)
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) {
var stdout = ''
var child
try {
child = cp.spawn(command, args)
} catch (err) {
// Spawn can throw an error
process.nextTick(function () {
cb(error, stdout)
})
return
}
child.stdout.on('data', function (data) {
stdout += data
})
var error = null
child.on('error', function (processError) {
error = processError
})
child.on('close', function (code, signal) {
if (code !== 0 && !error) error = new Error('Command failed: #{signal || code}')
if (error) error.stdout = stdout
cb(error, stdout)
})
}
/**
* Spawn 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) {
var homeDir = os.homedir()
if (homeDir) {
var desktopShortcutPath = path.join(homeDir, 'Desktop', 'WebTorrent.lnk')
// If the desktop shortcut was deleted by the user, then keep it deleted.
fs.access(desktopShortcutPath, function (err) {
var desktopShortcutExists = !err
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,23 +0,0 @@
import { APP_NAME } from '../config.js'
import AutoLaunch from 'auto-launch'
const appLauncher = new AutoLaunch({
name: APP_NAME,
isHidden: true
})
export function install () {
return appLauncher
.isEnabled()
.then(enabled => {
if (!enabled) return appLauncher.enable()
})
}
export function uninstall () {
return appLauncher
.isEnabled()
.then(enabled => {
if (enabled) return appLauncher.disable()
})
}

View File

@@ -1,76 +1,54 @@
import path from 'path' module.exports = {
import config from '../config.js' disable,
import * as windows from './windows' enable,
onPlayerPause,
onPlayerPlay
}
const PREV_ICON = path.join(config.STATIC_PATH, 'PreviousTrackThumbnailBarButton.png') /**
const PLAY_ICON = path.join(config.STATIC_PATH, 'PlayThumbnailBarButton.png') * On Windows, add a "thumbnail toolbar" with a play/pause button in the taskbar.
const PAUSE_ICON = path.join(config.STATIC_PATH, 'PauseThumbnailBarButton.png') * This provides users a way to access play/pause functionality without restoring
const NEXT_ICON = path.join(config.STATIC_PATH, 'NextTrackThumbnailBarButton.png') * or activating the window.
*/
// Array indices for each button var path = require('path')
const PREV = 0 var config = require('../config')
const PLAY_PAUSE = 1
const NEXT = 2
let buttons = [] var windows = require('./windows')
/** /**
* Show the Windows thumbnail toolbar buttons. * Show the Windows thumbnail toolbar buttons.
*/ */
export function enable () { function enable () {
buttons = [ update(false)
{
tooltip: 'Previous Track',
icon: PREV_ICON,
click: () => windows.main.dispatch('previousTrack')
},
{
tooltip: 'Pause',
icon: PAUSE_ICON,
click: () => windows.main.dispatch('playPause')
},
{
tooltip: 'Next Track',
icon: NEXT_ICON,
click: () => windows.main.dispatch('nextTrack')
}
]
update()
} }
/** /**
* Hide the Windows thumbnail toolbar buttons. * Hide the Windows thumbnail toolbar buttons.
*/ */
export function disable () { function disable () {
buttons = [] windows.main.win.setThumbarButtons([])
update()
} }
export function onPlayerPause () { function onPlayerPause () {
if (!isEnabled()) return update(true)
buttons[PLAY_PAUSE].tooltip = 'Play'
buttons[PLAY_PAUSE].icon = PLAY_ICON
update()
} }
export function onPlayerPlay () { function onPlayerPlay () {
if (!isEnabled()) return update(false)
buttons[PLAY_PAUSE].tooltip = 'Pause'
buttons[PLAY_PAUSE].icon = PAUSE_ICON
update()
} }
export function onPlayerUpdate (state) { function update (isPaused) {
if (!isEnabled()) return var icon = isPaused
buttons[PREV].flags = [state.hasPrevious ? 'enabled' : 'disabled'] ? 'PlayThumbnailBarButton.png'
buttons[NEXT].flags = [state.hasNext ? 'enabled' : 'disabled'] : 'PauseThumbnailBarButton.png'
update()
}
function isEnabled () { var buttons = [
return buttons.length > 0 {
} tooltip: isPaused ? 'Play' : 'Pause',
icon: path.join(config.STATIC_PATH, icon),
function update () { click: () => windows.main.dispatch('playPause')
}
]
windows.main.win.setThumbarButtons(buttons) windows.main.win.setThumbarButtons(buttons)
} }

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
}
let tray var electron = require('electron')
export function init () { var app = electron.app
var config = require('../config')
var windows = require('./windows')
var tray
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') var 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()) var contextMenu = electron.Menu.buildFromTemplate(getMenuTemplate())
tray.setContextMenu(contextMenu) tray.setContextMenu(contextMenu)
} }

View File

@@ -1,15 +1,17 @@
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 var electron = require('electron')
var get = require('simple-get')
const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL + var config = require('../config')
var log = require('./log')
var windows = require('./windows')
var AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
'?version=' + config.APP_VERSION + '?version=' + config.APP_VERSION +
'&platform=' + process.platform + '&platform=' + process.platform
'&sysarch=' + config.OS_SYSARCH
function init () { function init () {
if (process.platform === 'linux') { if (process.platform === 'linux') {
@@ -19,8 +21,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 +46,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
}
var electron = require('electron')
var 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,22 +1,21 @@
import config from '../../config.js' var 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
} }
var config = require('../../config')
var electron = require('electron')
function init () { function init () {
if (about.win) { if (about.win) {
return about.win.show() return about.win.show()
} }
const win = about.win = new electron.BrowserWindow({ var win = about.win = new electron.BrowserWindow({
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.webContents.once('did-finish-load', 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' var 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,92 +14,65 @@ export const main = {
win: null win: null
} }
function init (state, options) { var electron = require('electron')
var app = electron.app
var config = require('../../config')
var log = require('../log')
var menu = require('../menu')
var tray = require('../tray')
var HEADER_HEIGHT = 38
var TORRENT_HEIGHT = 100
function init () {
if (main.win) { if (main.win) {
return main.win.show() return main.win.show()
} }
var win = main.win = new electron.BrowserWindow({
const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds)
const win = main.win = new BrowserWindow({
backgroundColor: '#282828', backgroundColor: '#282828',
darkTheme: true, // Forces dark theme (GTK+3) darkTheme: true, // Forces dark theme (GTK+3)
height: initialBounds.height,
icon: getIconPath(), // Window icon (Windows, Linux) icon: getIconPath(), // Window icon (Windows, Linux)
minHeight: config.WINDOW_MIN_HEIGHT,
minWidth: config.WINDOW_MIN_WIDTH, minWidth: config.WINDOW_MIN_WIDTH,
show: false, minHeight: config.WINDOW_MIN_HEIGHT,
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: 500,
webPreferences: { height: HEADER_HEIGHT + (TORRENT_HEIGHT * 6) // header height + 5 torrents
nodeIntegration: true,
contextIsolation: false,
enableBlinkFeatures: 'AudioVideoTracks',
enableRemoteModule: true,
backgroundThrottling: false
},
x: initialBounds.x,
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', () => { if (win.setSheetOffset) win.setSheetOffset(HEADER_HEIGHT)
if (!options.hidden) win.show()
})
if (win.setSheetOffset) { win.webContents.on('dom-ready', function () {
win.setSheetOffset(config.UI_HEADER_HEIGHT)
}
win.webContents.on('dom-ready', () => {
menu.onToggleFullScreen(main.win.isFullScreen()) menu.onToggleFullScreen(main.win.isFullScreen())
}) })
win.webContents.on('will-navigate', (e) => {
// Prevent drag-and-drop from navigating the Electron window, which can happen
// before our drag-and-drop handlers have been initialized.
e.preventDefault()
})
win.on('blur', onWindowBlur) win.on('blur', onWindowBlur)
win.on('focus', onWindowFocus) win.on('focus', onWindowFocus)
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('close', function (e) {
send('windowBoundsChanged', e.sender.getBounds()) if (process.platform !== 'darwin' && !tray.hasTray()) {
}, 1000)) app.quit()
} else if (!app.isQuitting) {
win.on('resize', debounce(e => {
send('windowBoundsChanged', e.sender.getBounds())
}, 1000))
win.on('close', async e => {
if (process.platform !== 'darwin') {
const { hasTray } = await import('../tray.js')
if (!hasTray()) {
app.quit()
return
}
}
if (!app.isQuitting) {
e.preventDefault() e.preventDefault()
hide() hide()
} }
@@ -121,7 +85,7 @@ function dispatch (...args) {
function hide () { function hide () {
if (!main.win) return if (!main.win) return
dispatch('backToList') main.win.send('dispatch', 'backToList')
main.win.hide() main.win.hide()
} }
@@ -145,29 +109,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()) { var 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()) var 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 +188,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 })
} }
} }
@@ -239,20 +211,12 @@ function toggleFullScreen (flag) {
function onWindowBlur () { function onWindowBlur () {
menu.setWindowFocus(false) menu.setWindowFocus(false)
tray.setWindowFocus(false)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(false)
}
} }
function onWindowFocus () { function onWindowFocus () {
menu.setWindowFocus(true) menu.setWindowFocus(true)
tray.setWindowFocus(true)
if (process.platform !== 'darwin') {
const tray = require('../tray')
tray.setWindowFocus(true)
}
} }
function getIconPath () { function getIconPath () {

View File

@@ -1,8 +1,4 @@
import electron from '../../../electron.cjs' var 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,8 +6,13 @@ export const webtorrent = {
win: null win: null
} }
var electron = require('electron')
var config = require('../../config')
var log = require('../log')
function init () { function init () {
const win = webtorrent.win = new electron.BrowserWindow({ var win = webtorrent.win = new electron.BrowserWindow({
backgroundColor: '#1E1E1E', backgroundColor: '#1E1E1E',
center: true, center: true,
fullscreen: false, fullscreen: 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
} }
@@ -57,10 +52,11 @@ function send (...args) {
function toggleDevTools () { function toggleDevTools () {
if (!webtorrent.win) return if (!webtorrent.win) return
log('toggleDevTools')
if (webtorrent.win.webContents.isDevToolsOpened()) { if (webtorrent.win.webContents.isDevToolsOpened()) {
webtorrent.win.webContents.closeDevTools() webtorrent.win.webContents.closeDevTools()
webtorrent.win.hide() webtorrent.win.hide()
} else { } else {
webtorrent.win.webContents.openDevTools({ mode: 'detach' }) webtorrent.win.webContents.openDevTools({ detach: true })
} }
} }

View File

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

View File

@@ -0,0 +1,93 @@
const colors = require('material-ui/styles/colors')
const electron = require('electron')
const React = require('react')
const remote = electron.remote
const RaisedButton = require('material-ui/RaisedButton').default
const TextField = require('material-ui/TextField').default
class PathSelector extends React.Component {
static get propTypes () {
return {
className: React.PropTypes.string,
dialog: React.PropTypes.object,
displayValue: React.PropTypes.string,
id: React.PropTypes.string,
onChange: React.PropTypes.func,
title: React.PropTypes.string.isRequired,
value: React.PropTypes.string
}
}
constructor (props) {
super(props)
this.handleClick = this.handleClick.bind(this)
}
handleClick () {
var opts = Object.assign({
defaultPath: this.props.value,
properties: [ 'openFile', 'openDirectory' ]
}, this.props.dialog)
remote.dialog.showOpenDialog(
remote.getCurrentWindow(),
opts,
(filenames) => {
if (!Array.isArray(filenames)) return
this.props.onChange && this.props.onChange(filenames[0])
}
)
}
render () {
const id = this.props.title.replace(' ', '-').toLowerCase()
return (
<div
className={this.props.className}
style={{
alignItems: 'center',
display: 'flex',
width: '100%'
}}
>
<div
className='label'
style={{
flex: '0 auto',
marginRight: 10,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{this.props.title}:
</div>
<TextField
className='control'
disabled
id={id}
inputStyle={{
color: colors.grey50
}}
style={{
flex: '1',
fontSize: 14
}}
value={this.props.displayValue || this.props.value}
/>
<RaisedButton
className='control'
label='Change'
onClick={this.handleClick}
style={{
marginLeft: 10
}}
/>
</div>
)
}
}
module.exports = PathSelector

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 FlatButton = require('material-ui/FlatButton').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)
} }
@@ -39,10 +39,11 @@ export default class ShowMore extends React.Component {
? this.props.hideLabel ? this.props.hideLabel
: this.props.showLabel : this.props.showLabel
return ( return (
<div className='show-more' style={this.props.style}> <div
style={this.props.style}
>
{this.state.expanded ? this.props.children : null} {this.state.expanded ? this.props.children : null}
<RaisedButton <FlatButton
className='control'
onClick={this.handleClick} onClick={this.handleClick}
label={label} label={label}
/> />
@@ -50,3 +51,5 @@ export default class ShowMore extends React.Component {
) )
} }
} }
module.exports = ShowMore

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,24 @@
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 var loc = this.props.state.location
return ( return (
<div <div className='header'>
className='header'
onMouseMove={dispatcher('mediaMouseMoved')}
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
onMouseLeave={dispatcher('mediaControlsMouseLeave')}
>
{this.getTitle()} {this.getTitle()}
<div className='nav left float-left'> <div className='nav left float-left'>
<i <i
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>
@@ -37,21 +31,22 @@ export default class Header extends React.Component {
getTitle () { getTitle () {
if (process.platform !== 'darwin') return null if (process.platform !== 'darwin') return null
const state = this.props.state var state = this.props.state
return (<div className='title ellipsis'>{state.window.title}</div>) return (<div className='title ellipsis'>{state.window.title}</div>)
} }
getAddButton () { getAddButton () {
const state = this.props.state var state = this.props.state
if (state.location.url() !== 'home') return null if (state.location.url() !== 'home') return null
return ( return (
<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,32 +0,0 @@
import * as React from 'react'
import PropTypes from 'prop-types'
import * as colors from 'material-ui/styles/colors'
export default class Heading extends React.Component {
static get propTypes () {
return {
level: PropTypes.number
}
}
static get defaultProps () {
return {
level: 1
}
}
render () {
const HeadingTag = 'h' + this.props.level
const style = {
color: colors.grey100,
fontSize: 20,
marginBottom: 15,
marginTop: 30
}
return (
<HeadingTag style={style}>
{this.props.children}
</HeadingTag>
)
}
}

View File

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

View File

@@ -1,53 +1,32 @@
import * as React from 'react' const React = require('react')
import textField from 'material-ui/TextField'
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 {dispatch, dispatcher} = require('../lib/dispatcher')
const { clipboard } = electron
export default class OpenTorrentAddressModal extends React.Component { module.exports = class OpenTorrentAddressModal extends React.Component {
render () { render () {
// TODO: dcposch remove janky inline <script>
return ( return (
<div className='open-torrent-address-modal'> <div className='open-torrent-address-modal'>
<p><label>Enter torrent address or magnet link</label></p> <p><label>Enter torrent address or magnet link</label></p>
<div> <p>
<TextField <input id='add-torrent-url' type='text' onKeyPress={handleKeyPress} />
id='torrent-address-field' </p>
className='control' <p className='float-right'>
ref={(c) => { this.torrentURL = c }} <button className='button button-flat' onClick={dispatcher('exitModal')}>Cancel</button>
fullWidth <button className='button button-raised' onClick={handleOK}>OK</button>
onKeyDown={handleKeyDown.bind(this)} </p>
/> <script>document.querySelector('#add-torrent-url').focus()</script>
</div>
<ModalOKCancel
cancelText='CANCEL'
onCancel={dispatcher('exitModal')}
okText='OK'
onOK={handleOK.bind(this)}
/>
</div> </div>
) )
} }
componentDidMount () {
this.torrentURL.input.focus()
const clipboardContent = clipboard.readText()
if (isMagnetLink(clipboardContent)) {
this.torrentURL.input.value = clipboardContent
this.torrentURL.input.select()
}
}
} }
function handleKeyDown (e) { function handleKeyPress (e) {
if (e.which === 13) handleOK.call(this) /* hit Enter to submit */ if (e.which === 13) handleOK() /* hit Enter to submit */
} }
function handleOK () { function handleOK () {
dispatch('exitModal') dispatch('exitModal')
dispatch('addTorrent', this.torrentURL.input.value) // TODO: dcposch use React refs instead
dispatch('addTorrent', document.querySelector('#add-torrent-url').value)
} }

View File

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

View File

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

View File

@@ -1,44 +1,44 @@
import * as React from 'react' const React = require('react')
import { shell } from 'electron' const electron = require('electron')
import ModalOKCancel from './modal-ok-cancel.js' const path = require('path')
import { dispatcher } from '../lib/dispatcher.js'
export default class UnsupportedMediaModal extends React.Component { const {dispatcher} = require('../lib/dispatcher')
module.exports = class UnsupportedMediaModal extends React.Component {
render () { render () {
const state = this.props.state var state = this.props.state
const err = state.modal.error var err = state.modal.error
const message = (err && err.getMessage) var message = (err && err.getMessage)
? err.getMessage() ? err.getMessage()
: err : err
const onAction = state.modal.externalPlayerInstalled var playerPath = state.saved.prefs.externalPlayerPath
? dispatcher('openExternalPlayer') var playerName = playerPath
: () => this.onInstall() ? path.basename(playerPath).split('.')[0]
const actionText = state.modal.externalPlayerInstalled : 'VLC'
? 'PLAY IN ' + state.getExternalPlayerName().toUpperCase() var actionButton = state.modal.externalPlayerInstalled
: 'INSTALL VLC' ? (<button className='button-raised' onClick={dispatcher('openExternalPlayer')}>Play in {playerName}</button>)
const errorMessage = state.modal.externalPlayerNotFound : (<button className='button-raised' onClick={() => this.onInstall}>Install VLC</button>)
var playerMessage = state.modal.externalPlayerNotFound
? 'Couldn\'t run external player. Please make sure it\'s installed.' ? 'Couldn\'t run external player. Please make sure it\'s installed.'
: '' : ''
return ( return (
<div> <div>
<p><strong>Sorry, we can't play that file.</strong></p> <p><strong>Sorry, we can't play that file.</strong></p>
<p>{message}</p> <p>{message}</p>
<ModalOKCancel <p className='float-right'>
cancelText='CANCEL' <button className='button-flat' onClick={dispatcher('backToList')}>Cancel</button>
onCancel={dispatcher('backToList')} {actionButton}
okText={actionText} </p>
onOK={onAction} <p className='error-text'>{playerMessage}</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 var state = this.props.state
state.modal.externalPlayerInstalled = true // Assume they'll install it successfully state.modal.externalPlayerInstalled = true // Assume they'll install it successfully
} }
} }

View File

@@ -1,30 +1,24 @@
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 {dispatch} = require('../lib/dispatcher')
module.exports = class UpdateAvailableModal extends React.Component {
render () { render () {
const state = this.props.state var state = this.props.state
return ( return (
<div className='update-available-modal'> <div className='update-available-modal'>
<p><strong>A new version of WebTorrent is available: v{state.modal.version}</strong></p> <p><strong>A new version of WebTorrent is available: v{state.modal.version}</strong></p>
<p> <p>We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so you'll have to download the new version manually.</p>
We have an auto-updater for Windows and Mac. <p className='float-right'>
We don't have one for Linux yet, so you'll have to download the new version manually. <button className='button button-flat' onClick={handleSkip}>Skip This Release</button>
<button className='button button-raised' onClick={handleShow}>Show Download Page</button>
</p> </p>
<ModalOKCancel
cancelText='SKIP THIS RELEASE'
onCancel={handleSkip}
okText='SHOW DOWNLOAD PAGE'
onOK={handleShow}
/>
</div> </div>
) )
function handleShow () { function handleShow () {
// 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,14 +1,16 @@
import { ipcRenderer } from 'electron' const electron = require('electron')
import telemetry from '../lib/telemetry.js'
import Playlist from '../lib/playlist.js'
export default class MediaController { const ipcRenderer = electron.ipcRenderer
// Controls local play back: the <video>/<audio> tag and VLC
// Does not control remote casting (Chromecast etc)
module.exports = class MediaController {
constructor (state) { constructor (state) {
this.state = state this.state = state
} }
mediaSuccess () { mediaSuccess () {
telemetry.logPlayAttempt('success') this.state.playing.result = 'success'
} }
mediaStalled () { mediaStalled () {
@@ -16,15 +18,15 @@ export default class MediaController {
} }
mediaError (error) { mediaError (error) {
const state = this.state var state = this.state
if (state.location.url() === 'player') { if (state.location.url() === 'player') {
telemetry.logPlayAttempt('error') state.playing.result = '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
} }
}) })
@@ -40,37 +42,14 @@ export default class MediaController {
this.state.playing.mouseStationarySince = new Date().getTime() this.state.playing.mouseStationarySince = new Date().getTime()
} }
controlsMouseEnter () {
this.state.playing.mouseInControls = true
this.state.playing.mouseStationarySince = new Date().getTime()
}
controlsMouseLeave () {
this.state.playing.mouseInControls = false
this.state.playing.mouseStationarySince = new Date().getTime()
}
openExternalPlayer () { openExternalPlayer () {
const state = this.state var state = this.state
ipcRenderer.send('openExternalPlayer', state.saved.prefs.externalPlayerPath, state.server.localURL, state.window.title)
state.playing.location = 'external' state.playing.location = 'external'
const onServerRunning = () => {
state.playing.isReady = true
telemetry.logPlayAttempt('external')
const mediaURL = Playlist.getCurrentLocalURL(state)
ipcRenderer.send('openExternalPlayer',
state.saved.prefs.externalPlayerPath,
mediaURL,
state.window.title)
}
if (state.server != null) onServerRunning()
else ipcRenderer.once('wt-server-running', onServerRunning)
} }
externalPlayerNotFound () { externalPlayerNotFound () {
const modal = this.state.modal var modal = this.state.modal
if (modal && modal.id === 'unsupported-media-modal') { if (modal && modal.id === 'unsupported-media-modal') {
modal.externalPlayerNotFound = true modal.externalPlayerNotFound = true
} }

View File

@@ -1,29 +1,20 @@
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 errors = 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 State = require('../lib/state')
// Checks whether we are connected and already casting
// Returns false if we not casting (state.playing.location === 'local') const ipcRenderer = electron.ipcRenderer
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
function isCasting (state) { // Controls playback of torrents and files within torrents
return state.playing.location === 'chromecast' || // both local (<video>,<audio>,external player) and remote (cast)
state.playing.location === 'airplay' || module.exports = class PlaybackController {
state.playing.location === 'dlna'
}
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
@@ -33,88 +24,49 @@ export default class PlaybackController {
// Play a file in a torrent. // Play a file in a torrent.
// * Start torrenting, if necessary // * Start torrenting, if necessary
// * Stream, if not already fully downloaded // * Stream, if not already fully downloaded
// * If no file index is provided, restore the most recently viewed file or autoplay the first // * If no file index is provided, pick the default file to play
playFile (infoHash, index /* optional */) { playFile (infoHash, index /* optional */) {
this.pauseActiveTorrents(infoHash) this.state.location.go({
const state = this.state url: 'player',
if (state.location.url() === 'player') { setup: (cb) => {
this.updatePlayer(infoHash, index, false, (err) => { this.play()
if (err) { dispatch('error', err) } else { this.play() } this.openPlayer(infoHash, index, cb)
}) },
} else { destroy: () => this.closePlayer()
let initialized = false }, (err) => {
state.location.go({ if (err) dispatch('error', err)
url: 'player', })
setup: (cb) => {
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
if (index === undefined || initialized) index = torrentSummary.mostRecentFileIndex
if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable)
if (index === undefined) return cb(new UnplayableTorrentError())
initialized = true
this.openPlayer(infoHash, index, (err) => {
if (!err) this.play()
cb(err)
})
},
destroy: () => this.closePlayer()
}, (err) => {
if (err) { dispatch('error', err) }
})
}
} }
// Open a file in OS default app. // Open a file in OS default app.
openPath (infoHash, index) { openItem (infoHash, index) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const filePath = path.join(torrentSummary.path, torrentSummary.files[index].path) var 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 var 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') var 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)
nextTrack () {
const state = this.state
if (Playlist.hasNext(state) && state.playing.location !== 'external') {
this.updatePlayer(state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => {
if (err) { dispatch('error', err) } else { this.play() }
})
}
}
// Play previous track in list (if any)
previousTrack () {
const state = this.state
if (Playlist.hasPrevious(state) && state.playing.location !== 'external') {
this.updatePlayer(state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => {
if (err) { dispatch('error', err) } else { this.play() }
})
}
} }
// Play (unpause) the current media // Play (unpause) the current media
play () { play () {
const state = this.state var state = this.state
if (!state.playing.isPaused) return if (!state.playing.isPaused) return
state.playing.isPaused = false state.playing.isPaused = false
if (isCasting(state)) { if (isCasting(state)) {
@@ -125,7 +77,7 @@ export default class PlaybackController {
// Pause the currently playing media // Pause the currently playing media
pause () { pause () {
const state = this.state var state = this.state
if (state.playing.isPaused) return if (state.playing.isPaused) return
state.playing.isPaused = true state.playing.isPaused = true
if (isCasting(state)) { if (isCasting(state)) {
@@ -145,21 +97,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
@@ -167,23 +106,25 @@ export default class PlaybackController {
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc // to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
// until -16 (fast rewind) // until -16 (fast rewind)
changePlaybackRate (direction) { changePlaybackRate (direction) {
const state = this.state var state = this.state
let rate = state.playing.playbackRate var rate = state.playing.playbackRate
if (direction > 0 && rate < 2) { if (direction > 0 && rate >= 0.25 && rate < 2) {
rate += 0.25 rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) { } else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25 rate -= 0.25
} else if (direction > 0 && rate >= 1 && rate < 16) { } else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */
rate = -1
} else if (direction > 0 && rate === -1) {
rate = 0.25
} else if ((direction > 0 && rate >= 1 && rate < 16) || (direction < 0 && rate > -16 && rate <= -1)) {
rate *= 2 rate *= 2
} else if (direction < 0 && rate > 1 && rate <= 16) { } else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) {
rate /= 2 rate /= 2
} }
state.playing.playbackRate = rate state.playing.playbackRate = rate
if (isCasting(state) && !Cast.setRate(rate)) { if (isCasting(state) && !Cast.setRate(rate)) {
state.playing.playbackRate = 1 state.playing.playbackRate = 1
} }
// Wait a bit before we hide the controls and header again
state.playing.mouseStationarySince = new Date().getTime()
} }
// Change the volume, in range [0, 1], by some amount // Change the volume, in range [0, 1], by some amount
@@ -197,7 +138,8 @@ 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
var state = this.state
if (isCasting(state)) { if (isCasting(state)) {
Cast.setVolume(volume) Cast.setVolume(volume)
} else { } else {
@@ -211,8 +153,13 @@ export default class PlaybackController {
// * The video is paused // * The video is paused
// * The video is playing remotely on Chromecast or Airplay // * The video is playing remotely on Chromecast or Airplay
showOrHidePlayerControls () { showOrHidePlayerControls () {
const state = this.state var state = this.state
const hideControls = state.shouldHidePlayerControls() var hideControls = state.location.url() === 'player' &&
state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused &&
state.playing.location === 'local'
if (hideControls !== state.playing.hideControls) { if (hideControls !== state.playing.hideControls) {
state.playing.hideControls = hideControls state.playing.hideControls = hideControls
return true return true
@@ -222,97 +169,105 @@ export default class PlaybackController {
// Opens the video player to a specific torrent // Opens the video player to a specific torrent
openPlayer (infoHash, index, cb) { openPlayer (infoHash, index, cb) {
const state = this.state var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
state.playing.infoHash = torrentSummary.infoHash // automatically choose which file in the torrent to play, if necessary
state.playing.isReady = false if (index === undefined) index = torrentSummary.defaultPlayFileIndex
// update UI to show pending playback if (index === undefined) index = TorrentPlayer.pickFileToPlay(torrentSummary.files)
sound.play('PLAY') if (index === undefined) return cb(new errors.UnplayableError())
this.startServer(torrentSummary)
ipcRenderer.send('onPlayerOpen') // update UI to show pending playback
this.updatePlayer(infoHash, index, true, cb) if (torrentSummary.progress !== 1) sound.play('PLAY')
} // TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'requested'
this.update()
var timeout = setTimeout(() => {
telemetry.logPlayAttempt('timeout')
// TODO: remove torrentSummary.playStatus
torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR')
cb(new Error('Playback timed out. Try again.'))
this.update()
}, 10000) /* give it a few seconds */
// Starts WebTorrent server for media streaming
startServer (torrentSummary) {
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,
() => this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb))
} else { } else {
onTorrentReady() this.openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)
}
function onTorrentReady () {
ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
ipcRenderer.once('wt-server-running', () => { state.playing.isReady = true })
} }
} }
// Called each time the current file changes openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
updatePlayer (infoHash, index, resume, cb) { var fileSummary = torrentSummary.files[index]
const state = this.state
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
const fileSummary = torrentSummary.files[index]
if (!TorrentPlayer.isPlayable(fileSummary)) {
torrentSummary.mostRecentFileIndex = undefined
return cb(new UnplayableFileError())
}
torrentSummary.mostRecentFileIndex = index
// update state // update state
state.playing.infoHash = infoHash var state = this.state
state.playing.infoHash = torrentSummary.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' if (fileSummary.currentTime) {
// pick up where we left off var fraction = fileSummary.currentTime / fileSummary.duration
let jumpToTime = 0 var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (resume && fileSummary.currentTime) {
const fraction = fileSummary.currentTime / fileSummary.duration
const secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) { if (fraction < 0.9 && secondsLeft > 10) {
jumpToTime = fileSummary.currentTime state.playing.jumpToTime = fileSummary.currentTime
} }
} }
state.playing.jumpToTime = jumpToTime
// if it's audio, parse out the metadata (artist, title, etc) // if it's audio, parse out the metadata (artist, title, etc)
if (torrentSummary.status === 'paused') { if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, getAudioMetadata) ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
} else {
getAudioMetadata()
}
function getAudioMetadata () {
if (state.playing.type === 'audio') {
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
// play in VLC if set as default player (Preferences / Playback / Play in VLC) ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index)
if (this.state.saved.prefs.openExternalPlayer) { ipcRenderer.once('wt-server-' + torrentSummary.infoHash, (e, info) => {
dispatch('openExternalPlayer') clearTimeout(timeout)
// if we timed out (user clicked play a long time ago), don't autoplay
var timedOut = torrentSummary.playStatus === 'timeout'
delete torrentSummary.playStatus
if (timedOut) {
ipcRenderer.send('wt-stop-server')
return this.update()
}
state.window.title = torrentSummary.files[state.playing.fileIndex].name
// play in VLC if set as default player (Preferences / Playback / Play in VLC)
if (this.state.saved.prefs.openExternalPlayer) {
dispatch('openExternalPlayer')
this.update()
cb()
return
}
// otherwise, play the video
this.update() this.update()
ipcRenderer.send('onPlayerOpen')
cb() cb()
return })
}
// otherwise, play the video
this.update()
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
cb()
} }
closePlayer () { closePlayer () {
console.log('closePlayer') console.log('closePlayer')
// Quit any external players, like Chromecast/Airplay/etc or VLC // Quit any external players, like Chromecast/Airplay/etc or VLC
const state = this.state var state = this.state
if (isCasting(state)) { if (isCasting(state)) {
Cast.stop() Cast.stop()
} }
@@ -323,7 +278,12 @@ export default class PlaybackController {
// Save volume (this session only, not in state.saved) // Save volume (this session only, not in state.saved)
state.previousVolume = state.playing.volume state.previousVolume = state.playing.volume
if (!state.playing.isReady) telemetry.logPlayAttempt('abandoned') // user gave up waiting // Telemetry: track what happens after the user clicks play
var result = state.playing.result // 'success' or 'error'
if (result === 'success') telemetry.logPlayAttempt('success') // first frame displayed
else if (result === 'error') telemetry.logPlayAttempt('error') // codec missing, etc
else if (result === undefined) telemetry.logPlayAttempt('abandoned') // user exited before first frame
else console.error('Unknown state.playing.result', state.playing.result)
// Reset the window contents back to the home screen // Reset the window contents back to the home screen
state.playing = State.getDefaultPlayState() state.playing = State.getDefaultPlayState()
@@ -334,13 +294,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,9 @@
import { dispatch } from '../lib/dispatcher.js' const State = require('../lib/state')
import { ipcRenderer } from 'electron' const {dispatch} = require('../lib/dispatcher')
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
@@ -9,29 +11,46 @@ export default class PrefsController {
// Goes to the Preferences screen // Goes to the Preferences screen
show () { show () {
const state = this.state var 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: 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) var path = property.split('.')
else if (property === 'startup') ipcRenderer.send('setStartup', value) var key = this.state.unsaved.prefs
for (var i = 0; i < path.length - 1; i++) {
if (typeof key[path[i]] === 'undefined') {
key[path[i]] = {}
}
key = key[path[i]]
}
key[path[i]] = value
}
this.state.saved.prefs[property] = value // All unsaved prefs take effect atomically, and are saved to config.json
dispatch('stateSaveImmediate') save () {
var state = this.state
if (state.unsaved.prefs.isFileHandler !== state.saved.prefs.isFileHandler) {
ipcRenderer.send('setDefaultFileHandler', state.unsaved.prefs.isFileHandler)
}
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
State.save(state)
dispatch('checkDownloadPath') dispatch('checkDownloadPath')
} }
} }

View File

@@ -1,22 +1,28 @@
import remote from '@electron/remote' const electron = require('electron')
import fs from 'fs' const fs = require('fs-extra')
import path from 'path' const path = require('path')
import parallel from 'run-parallel' const parallel = require('run-parallel')
import { dispatch } from '../lib/dispatcher.js' const zeroFill = require('zero-fill')
export default class SubtitlesController { const remote = electron.remote
const ipcRenderer = electron.ipcRenderer
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) {
@@ -24,36 +30,38 @@ export default class SubtitlesController {
} }
toggleSubtitlesMenu () { toggleSubtitlesMenu () {
const subtitles = this.state.playing.subtitles var subtitles = this.state.playing.subtitles
subtitles.showMenu = !subtitles.showMenu subtitles.showMenu = !subtitles.showMenu
} }
addSubtitles (files, autoSelect) { addSubtitles (files, autoSelect) {
var state = this.state
// Subtitles are only supported when playing video files // Subtitles are only supported when playing video files
if (this.state.playing.type !== 'video') return if (state.playing.type !== 'video') return
if (files.length === 0) return if (files.length === 0) return
const subtitles = this.state.playing.subtitles var subtitles = state.playing.subtitles
// 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)) var 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 (var i = 0; i < tracks.length; i++) {
tracks.forEach((track, i) => { // No dupes allowed
let trackIndex = subtitles.tracks.findIndex((t) => var track = tracks[i]
track.filePath === t.filePath) var trackIndex = state.playing.subtitles.tracks
.findIndex((t) => track.filePath === t.filePath)
// Add the track // Add the track
if (trackIndex === -1) { if (trackIndex === -1) {
trackIndex = subtitles.tracks.push(track) - 1 trackIndex = state.playing.subtitles.tracks.push(track) - 1
} }
// If we're auto-selecting a track, try to find one in the user's language // If we're auto-selecting a track, try to find one in the user's language
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) { if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
subtitles.selectedIndex = trackIndex state.playing.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)
@@ -62,50 +70,66 @@ export default class SubtitlesController {
checkForSubtitles () { checkForSubtitles () {
if (this.state.playing.type !== 'video') return if (this.state.playing.type !== 'video') return
const torrentSummary = this.state.getPlayingTorrentSummary() var torrentSummary = this.state.getPlayingTorrentSummary()
if (!torrentSummary || !torrentSummary.progress) return if (!torrentSummary || !torrentSummary.progress) return
torrentSummary.progress.files.forEach((fp, ix) => { torrentSummary.progress.files.forEach((fp, ix) => {
if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files
const file = torrentSummary.files[ix] var file = torrentSummary.files[ix]
if (this.state.playing.fileIndex === ix) return this.checkForEmbeddedMKVSubtitles(file)
if (!this.isSubtitle(file.name)) return if (!this.isSubtitle(file.name)) return
const filePath = path.join(torrentSummary.path, file.path) var filePath = path.join(torrentSummary.path, file.path)
this.addSubtitles([filePath], false) this.addSubtitles([filePath], false)
}) })
} }
isSubtitle (file) { isSubtitle (file) {
const name = typeof file === 'string' ? file : file.name var name = typeof file === 'string' ? file : file.name
const ext = path.extname(name).toLowerCase() var ext = path.extname(name).toLowerCase()
return ext === '.srt' || ext === '.vtt' return ext === '.srt' || ext === '.vtt'
} }
checkForEmbeddedMKVSubtitles (file) {
var playing = this.state.playing
// var playingFile = this.state.getPlayingFileSummary()
// var playingPath = path.join(torrentSummary.path, playingFile.path)
if (path.extname(file.name).toLowerCase() === '.mkv') {
ipcRenderer.send('wt-get-mkv-subtitles', playing.infoHash, playing.fileIndex)
ipcRenderer.once('wt-mkv-subtitles', function (e, tracks) {
tracks.forEach(function (trackEntry) {
var track = loadEmbeddedSubtitle(trackEntry)
console.log('loaded emb subs', track)
playing.subtitles.tracks.push(track)
})
if (tracks.length > 0) relabelSubtitles(playing.subtitles)
})
}
}
} }
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') var concat = require('simple-concat')
const LanguageDetect = await import('languagedetect') var srtToVtt = require('srt-to-vtt')
const srtToVtt = await import('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 var filePath = file.path || file
const vttStream = fs.createReadStream(filePath).pipe(srtToVtt()) var 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 var langDetected = detectVTTLanguage(buf)
const vttContents = buf.toString().replace(/(.*-->.*)/g, '')
let langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
const track = { var track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'), buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected, language: langDetected,
label: langDetected, label: langDetected,
filePath filePath: filePath
} }
cb(null, track) cb(null, track)
@@ -114,20 +138,66 @@ 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') var iso639 = require('iso-639-1')
const osLangISO = window.navigator.language.split('-')[0] // eg 'en' var osLangISO = window.navigator.language.split('-')[0] // eg 'en'
const langIso = iso639.getCode(language) // eg 'de' if language is 'German' var langIso = iso639.getCode(language) // eg 'de' if language is 'German'
return langIso === osLangISO return langIso === osLangISO
} }
// Make sure we don't have two subtitle tracks with the same label // Make sure we don't have two subtitle tracks with the same label
// Labels each track by language, eg 'German', 'English', 'English 2', ... // Labels each track by language, eg 'German', 'English', 'English 2', ...
function relabelSubtitles (subtitles) { function relabelSubtitles (subtitles) {
const counts = {} var counts = {}
subtitles.tracks.forEach(track => { subtitles.tracks.forEach(function (track) {
const lang = track.language var 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
}) })
} }
function detectVTTLanguage (buffer) {
var LanguageDetect = require('languagedetect')
// Detect what language the subtitles are in
var vttContents = buffer.toString().replace(/(.*-->.*)/g, '') // remove numbers?
var langDetected = (new LanguageDetect()).detect(vttContents, 2)
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
return langDetected
}
function loadEmbeddedSubtitle (trackEntry) {
// convert to .vtt format
var vtt = 'WEBVTT FILE\r\n\r\n'
trackEntry.subtitles.forEach(function (sub, i) {
vtt += `${i + 1}\r\n`
vtt += `${msToTime(sub.time)} --> ${msToTime(sub.time + sub.duration)}\r\n`
vtt += `${sub.text}\r\n\r\n`
})
function msToTime (s) {
var ms = s % 1000
s = (s - ms) / 1000
var secs = s % 60
s = (s - secs) / 60
var mins = s % 60
var hrs = (s - mins) / 60
var z = zeroFill
return z(2, hrs) + ':' + z(2, mins) + ':' + z(2, secs) + '.' + z(3, ms)
}
var buf = new Buffer(vtt)
var langDetected = detectVTTLanguage(buf)
var track = {
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected,
label: langDetected,
filePath: null
}
return track
}

View File

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

View File

@@ -1,42 +1,37 @@
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 State = require('../lib/state')
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
} }
// Adds a torrent to the list, starts downloading/seeding. // Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
// TorrentID can be a magnet URI, infohash, or torrent file: https://git.io/vik9M // magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
addTorrent (torrentId) { addTorrent (torrentId) {
if (torrentId.path) { if (torrentId.path) {
// Use path string instead of W3C File object // Use path string instead of W3C File object
torrentId = torrentId.path torrentId = torrentId.path
} }
// Trim extra spaces off pasted magnet links
if (typeof torrentId === 'string') {
torrentId = torrentId.trim()
}
// Allow a instant.io link to be pasted // Allow a instant.io link to be pasted
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) { if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
torrentId = torrentId.slice(torrentId.indexOf('#') + 1) torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
} }
const torrentKey = this.state.nextTorrentKey++ var torrentKey = this.state.nextTorrentKey++
const path = this.state.saved.prefs.downloadPath var path = this.state.saved.prefs.downloadPath
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path) ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
@@ -55,7 +50,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)
@@ -71,16 +66,16 @@ export default class TorrentListController {
// Creates a new torrent and start seeeding // Creates a new torrent and start seeeding
createTorrent (options) { createTorrent (options) {
const state = this.state var state = this.state
const torrentKey = state.nextTorrentKey++ var torrentKey = state.nextTorrentKey++
ipcRenderer.send('wt-create-torrent', torrentKey, options) ipcRenderer.send('wt-create-torrent', torrentKey, options)
state.location.cancel() state.location.cancel()
} }
// Starts downloading and/or seeding a given torrentSummary. // Starts downloading and/or seeding a given torrentSummary.
startTorrentingSummary (torrentKey) { startTorrentingSummary (torrentKey) {
const s = TorrentSummary.getByKey(this.state, torrentKey) var s = TorrentSummary.getByKey(this.state, torrentKey)
if (!s) throw new TorrentKeyNotFoundError(torrentKey) if (!s) throw new Error('Missing key: ' + torrentKey)
// New torrent: give it a path // New torrent: give it a path
if (!s.path) { if (!s.path) {
@@ -89,16 +84,10 @@ export default class TorrentListController {
return start() return start()
} }
const fileOrFolder = TorrentSummary.getFileOrFolder(s)
// New torrent: metadata not yet received
if (!fileOrFolder) return start()
// Existing torrent: check that the path is still there // Existing torrent: check that the path is still there
fs.stat(fileOrFolder, err => { fs.stat(TorrentSummary.getFileOrFolder(s), function (err) {
if (err) { if (err) {
s.error = 'path-missing' s.error = 'path-missing'
dispatch('backToList')
return return
} }
start() start()
@@ -107,7 +96,7 @@ export default class TorrentListController {
function start () { function start () {
ipcRenderer.send('wt-start-torrenting', ipcRenderer.send('wt-start-torrenting',
s.torrentKey, s.torrentKey,
TorrentSummary.getTorrentId(s), TorrentSummary.getTorrentID(s),
s.path, s.path,
s.fileModtimes, s.fileModtimes,
s.selections) s.selections)
@@ -116,79 +105,24 @@ export default class TorrentListController {
// TODO: use torrentKey, not infoHash // TODO: use torrentKey, not infoHash
toggleTorrent (infoHash) { toggleTorrent (infoHash) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
if (torrentSummary.status === 'paused') { if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new' torrentSummary.status = 'new'
this.startTorrentingSummary(torrentSummary.torrentKey) this.startTorrentingSummary(torrentSummary.torrentKey)
sound.play('ENABLE') sound.play('ENABLE')
return } else {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
sound.play('DISABLE')
} }
this.pauseTorrent(torrentSummary, true)
}
pauseAllTorrents () {
this.state.saved.torrents.forEach((torrentSummary) => {
if (torrentSummary.status === 'downloading' ||
torrentSummary.status === 'seeding') {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
}
})
sound.play('DISABLE')
}
resumeAllTorrents () {
this.state.saved.torrents.forEach((torrentSummary) => {
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'downloading'
this.startTorrentingSummary(torrentSummary.torrentKey)
}
})
sound.play('ENABLE')
}
pauseTorrent (torrentSummary, playSound) {
torrentSummary.status = 'paused'
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
if (playSound) sound.play('DISABLE')
}
prioritizeTorrent (infoHash) {
this.state.saved.torrents
.filter(torrent => ['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) var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
torrentSummary.selections[index] = !torrentSummary.selections[index] torrentSummary.selections[index] = !torrentSummary.selections[index]
// Let the WebTorrent process know to start or stop fetching that file // Let the WebTorrent process know to start or stop fetching that file
if (torrentSummary.status !== 'paused') { ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
}
} }
confirmDeleteTorrent (infoHash, deleteData) { confirmDeleteTorrent (infoHash, deleteData) {
@@ -199,46 +133,28 @@ 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) {
const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash) ipcRenderer.send('wt-stop-torrenting', infoHash)
var index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) { if (index > -1) {
const summary = this.state.saved.torrents[index] var summary = this.state.saved.torrents[index]
deleteTorrentFile(summary, deleteData)
// remove torrent and poster file
deleteFile(TorrentSummary.getTorrentPath(summary))
deleteFile(TorrentSummary.getPosterPath(summary)) // TODO: will the css path hack affect windows?
// optionally delete the torrent data
if (deleteData) moveItemToTrash(summary)
// remove torrent from saved list // remove torrent from saved list
this.state.saved.torrents.splice(index, 1) this.state.saved.torrents.splice(index, 1)
dispatch('stateSave') State.saveThrottled(this.state)
// 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.location.clearForward('player') // prevent user from going forward to a deleted torrent
this.state.saved.torrents = []
dispatch('stateSave')
// prevent user from going forward to a deleted torrent
this.state.location.clearForward('player')
sound.play('DELETE') sound.play('DELETE')
} }
@@ -251,120 +167,78 @@ export default class TorrentListController {
} }
openTorrentContextMenu (infoHash) { openTorrentContextMenu (infoHash) {
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash) var torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
const menu = new remote.Menu() var 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: () => saveTorrentFileAs(torrentSummary)
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
// Shows a Save File dialog, then saves the .torrent file wherever the user requests
saveTorrentFileAs (torrentKey) {
const torrentSummary = TorrentSummary.getByKey(this.state, torrentKey)
if (!torrentSummary) throw new TorrentKeyNotFoundError(torrentKey)
const downloadPath = this.state.saved.prefs.downloadPath
const newFileName = path.parse(torrentSummary.name).name + '.torrent'
const win = remote.getCurrentWindow()
const opts = {
title: 'Save Torrent File',
defaultPath: path.join(downloadPath, newFileName),
filters: [
{ name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] }
],
buttonLabel: 'Save'
}
const savePath = remote.dialog.showSaveDialogSync(win, opts)
if (!savePath) return // They clicked Cancel
console.log('Saving torrent ' + torrentKey + ' to ' + savePath)
const torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, (err, torrentFile) => {
if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, err => {
if (err) return dispatch('error', err)
})
})
} }
} }
// Recursively finds {name, path, size} for all files in a folder // Recursively finds {name, path, size} for all files in a folder
// Calls `cb` on success, calls `onError` on failure // Calls `cb` on success, calls `onError` on failure
function findFilesRecursive (paths, cb_) { function findFilesRecursive (paths, cb) {
if (paths.length > 1) { if (paths.length > 1) {
let numComplete = 0 var numComplete = 0
const ret = [] var ret = []
paths.forEach(path => { paths.forEach(function (path) {
findFilesRecursive([path], fileObjs => { findFilesRecursive([path], function (fileObjs) {
ret.push(...fileObjs) ret = ret.concat(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)
} }
}) })
}) })
return return
} }
const fileOrFolder = paths[0] var 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 var filePath = fileOrFolder
return cb_([{ return cb([{
name: path.basename(filePath), name: path.basename(filePath),
path: filePath, path: filePath,
size: stat.size size: stat.size
@@ -372,25 +246,25 @@ 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 var 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)) var 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)
}) })
} }
// Delete all files in a torrent // Delete all files in a torrent
function moveItemToTrash (torrentSummary) { function moveItemToTrash (torrentSummary) {
const filePath = TorrentSummary.getFileOrFolder(torrentSummary) var filePath = TorrentSummary.getFileOrFolder(torrentSummary)
if (filePath) ipcRenderer.send('moveItemToTrash', filePath) if (filePath) ipcRenderer.send('moveItemToTrash', filePath)
} }
@@ -398,13 +272,25 @@ function showItemInFolder (torrentSummary) {
ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary)) ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary))
} }
function deleteTorrentFile (torrentSummary, deleteData) { function saveTorrentFileAs (torrentSummary) {
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash) var downloadPath = this.state.saved.prefs.downloadPath
var newFileName = path.parse(torrentSummary.name).name + '.torrent'
// remove torrent and poster file var opts = {
deleteFile(TorrentSummary.getTorrentPath(torrentSummary)) title: 'Save Torrent File',
deleteFile(TorrentSummary.getPosterPath(torrentSummary)) defaultPath: path.join(downloadPath, newFileName),
filters: [
// optionally delete the torrent data { name: 'Torrent Files', extensions: ['torrent'] },
if (deleteData) moveItemToTrash(torrentSummary) { name: 'All Files', extensions: ['*'] }
]
}
electron.remote.dialog.showSaveDialog(electron.remote.getCurrentWindow(), opts, function (savePath) {
if (!savePath) return // They clicked Cancel
var torrentPath = TorrentSummary.getTorrentPath(torrentSummary)
fs.readFile(torrentPath, function (err, torrentFile) {
if (err) return dispatch('error', err)
fs.writeFile(savePath, torrentFile, function (err) {
if (err) return dispatch('error', err)
})
})
})
} }

View File

@@ -1,25 +1,26 @@
import { dispatch } from '../lib/dispatcher.js' const State = require('../lib/state')
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
} }
// Shows a modal saying that we have an update // Shows a modal saying that we have an update
updateAvailable (version) { updateAvailable (version) {
const skipped = this.state.saved.skippedVersions var skipped = this.state.saved.skippedVersions
if (skipped && skipped.includes(version)) { if (skipped && skipped.includes(version)) {
console.log('new version skipped by user: v' + version) console.log('new version skipped by user: v' + version)
return return
} }
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
skipVersion (version) { skipVersion (version) {
let skipped = this.state.saved.skippedVersions var skipped = this.state.saved.skippedVersions
if (!skipped) skipped = this.state.saved.skippedVersions = [] if (!skipped) skipped = this.state.saved.skippedVersions = []
skipped.push(version) skipped.push(version)
dispatch('stateSave') State.saveThrottled(this.state)
} }
} }

View File

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

View File

@@ -1,72 +1,65 @@
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
}
// Lazy load these for a ~300ms improvement in startup time // Lazy load these for a ~300ms improvement in startup time
let airplayer, chromecasts, dlnacasts var airplayer, chromecasts, dlnacasts
var config = require('../../config')
// 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 var state
// Callback to notify module users when state has changed // Callback to notify module users when state has changed
let update var update
// setInterval() for updating cast status // setInterval() for updating cast status
let statusInterval = null var statusInterval = null
// Start looking for cast devices on the local network // Start looking for cast devices on the local network
function init (appState, callback) { function init (appState, callback) {
state = appState state = appState
update = callback update = callback
// Don't actually cast during integration tests
// (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.)
if (config.IS_TEST) {
state.devices.chromecast = testPlayer('chromecast')
state.devices.airplay = testPlayer('airplay')
state.devices.dlna = testPlayer('dlna')
return
}
// Load modules, scan the network for devices // Load modules, scan the network for devices
airplayer = require('airplayer')() airplayer = require('airplayer')()
chromecasts = require('chromecasts')() chromecasts = require('chromecasts')()
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
function testPlayer (type) {
return {
getDevices,
open,
play,
pause,
stop,
status,
seek,
volume
}
function getDevices () {
return [{ name: type + '-1' }, { name: type + '-2' }]
}
function open () { }
function play () { }
function pause () { }
function stop () { }
function status () { }
function seek () { }
function volume () { }
}
// chromecast player implementation // chromecast player implementation
function chromecastPlayer () { function chromecastPlayer () {
const ret = { var ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -79,11 +72,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,79 +87,66 @@ 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) var torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
serveSubtitles(subtitlesUrl => { ret.device.play(state.server.networkURL, {
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, { type: 'video/mp4',
type: 'video/mp4', title: config.APP_NAME + ' - ' + torrentSummary.name
title: config.APP_NAME + ' - ' + torrentSummary.name, }, function (err) {
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(function (err, status) {
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.muted ? 0 : status.volume.level
update()
})
} }
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 = { var ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -177,8 +159,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 +177,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, function (err, res) {
if (err) { if (err) {
state.playing.location = 'local' state.playing.location = 'local'
state.errors.push({ state.errors.push({
@@ -211,17 +196,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,18 +224,21 @@ 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 = { var ret = {
device: null, device: null,
addDevice, addDevice,
getDevices, getDevices,
@@ -259,11 +251,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 +266,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) var 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, {
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 +292,50 @@ 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(function (err, status) {
if (err) return console.log('error getting %s status: %o', state.playing.location, err)
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.level
update()
})
} }
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) {
if (err || !status) {
return console.log('error getting %s status: %o', state.playing.location, err || 'missing response')
}
state.playing.isPaused = status.playerState === 'PAUSED'
state.playing.currentTime = status.currentTime
state.playing.volume = status.volume.muted ? 0 : status.volume.level
update()
}
// Start polling cast device state, whenever we're connected // Start polling cast device state, whenever we're connected
function startStatusInterval () { function startStatusInterval () {
statusInterval = setInterval(() => { statusInterval = setInterval(function () {
const player = getPlayer() var 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 +347,44 @@ 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 Error('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) var player = getPlayer(location)
const devices = player ? player.getDevices() : [] var devices = player ? player.getDevices() : []
if (devices.length === 0) { if (devices.length === 0) throw new Error('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 var {location, devices} = state.devices.castMenu
// Start casting // Start casting
const player = getPlayer(location) var 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() var player = getPlayer()
if (player) { if (player) {
player.stop(() => { player.stop(function () {
player.device = null player.device = null
stoppedCasting() stoppedCasting()
}) })
@@ -387,6 +393,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 +401,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,17 +415,20 @@ function getPlayer (location) {
return null return null
} }
} }
function play () { function play () {
const player = getPlayer() var player = getPlayer()
if (player) player.play(castCallback) if (player) player.play(castCallback)
} }
function pause () { function pause () {
const player = getPlayer() var player = getPlayer()
if (player) player.pause(castCallback) if (player) player.pause(castCallback)
} }
function setRate (rate) { function setRate (rate) {
let player var player
let result = true var result = true
if (state.playing.location === 'chromecast') { if (state.playing.location === 'chromecast') {
// TODO find how to control playback rate on chromecast // TODO find how to control playback rate on chromecast
castCallback() castCallback()
@@ -430,34 +441,17 @@ function setRate (rate) {
} }
return result return result
} }
function seek (time) { function seek (time) {
const player = getPlayer() var player = getPlayer()
if (player) { player.seek(time, castCallback) } if (player) player.seek(time, castCallback)
} }
function setVolume (volume) { function setVolume (volume) {
const player = getPlayer() var 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,36 +1,39 @@
const dispatchers = {} module.exports = {
let _dispatch = () => { } dispatch,
dispatcher,
setDispatch
}
var dispatchers = {}
var _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
// each update(). // each update().
function dispatcher (...args) { function dispatcher (...args) {
const str = JSON.stringify(args) var str = JSON.stringify(args)
let handler = dispatchers[str] var 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,43 +1,8 @@
import ExtendableError from 'es6-error' module.exports = {
/* Generic errors */ UnplayableError
class CastingError extends ExtendableError {
} }
class PlaybackError extends ExtendableError {
} function UnplayableError () {
class SoundError extends ExtendableError { this.message = 'Can\'t play any files in torrent'
}
class TorrentError extends ExtendableError {
}
/* Playback */
class UnplayableTorrentError extends PlaybackError {
constructor () { super('Can\'t play any files in torrent') }
}
class UnplayableFileError extends PlaybackError {
constructor () { super('Can\'t play that file') }
}
/* Sound */
class InvalidSoundNameError extends SoundError {
constructor (name) { super(`Invalid sound name: ${name}`) }
}
/* Torrent */
class TorrentKeyNotFoundError extends TorrentError {
constructor (torrentKey) { super(`Can't resolve torrent key ${torrentKey}`) }
}
export { CastingError }
export { PlaybackError }
export { SoundError }
export { TorrentError }
export { UnplayableTorrentError }
export { UnplayableFileError }
export { InvalidSoundNameError }
export { TorrentKeyNotFoundError }
export default {
CastingError,
PlaybackError,
SoundError,
TorrentError,
UnplayableTorrentError,
UnplayableFileError,
InvalidSoundNameError,
TorrentKeyNotFoundError
} }
UnplayableError.prototype = Error

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,13 @@
/* 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 semver = require('semver')
const config = require('../../config')
const TorrentSummary = require('./torrent-summary')
const fs = require('fs')
// Change `state.saved` (which will be saved back to config.json on exit) as // 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
@@ -12,39 +17,34 @@ function run (state) {
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
} }
const version = state.saved.version var version = state.saved.version
const saved = state.saved
if (semver.lt(version, '0.7.0')) migrate_0_7_0(saved) if (semver.lt(version, '0.7.0')) {
if (semver.lt(version, '0.7.2')) migrate_0_7_2(saved) migrate_0_7_0(state.saved)
if (semver.lt(version, '0.11.0')) migrate_0_11_0(saved) }
if (semver.lt(version, '0.12.0')) migrate_0_12_0(saved)
if (semver.lt(version, '0.14.0')) migrate_0_14_0(saved)
if (semver.lt(version, '0.17.0')) migrate_0_17_0(saved)
if (semver.lt(version, '0.17.2')) migrate_0_17_2(saved)
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)) { if (semver.lt(version, '0.7.2')) {
installHandlers(state.saved) migrate_0_7_2(state.saved)
}
if (semver.lt(version, '0.11.0')) {
migrate_0_11_0(state.saved)
}
if (semver.lt(version, '0.12.0')) {
migrate_0_12_0(state.saved)
} }
// 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 => { var fs = require('fs-extra')
const infoHash = ts.infoHash var path = require('path')
saved.torrents.forEach(function (ts) {
var infoHash = ts.infoHash
// Replace torrentPath with torrentFileName // Replace torrentPath with torrentFileName
// There are a number of cases to handle here: // There are a number of cases to handle here:
@@ -52,7 +52,7 @@ function migrate_0_7_0 (saved) {
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent' // * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent' // * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
// * Finally, now we're getting rid of torrentPath altogether // * Finally, now we're getting rid of torrentPath altogether
let src, dst var src, dst
if (ts.torrentPath) { if (ts.torrentPath) {
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) { if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
src = ts.torrentPath src = ts.torrentPath
@@ -62,7 +62,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) fs.copySync(src, dst)
delete ts.torrentPath delete ts.torrentPath
ts.torrentFileName = infoHash + '.torrent' ts.torrentFileName = infoHash + '.torrent'
@@ -70,20 +70,21 @@ function migrate_0_7_0 (saved) {
// Replace posterURL with posterFileName // Replace posterURL with posterFileName
if (ts.posterURL) { if (ts.posterURL) {
const extension = path.extname(ts.posterURL) var extension = path.extname(ts.posterURL)
src = path.isAbsolute(ts.posterURL) src = path.isAbsolute(ts.posterURL)
? ts.posterURL ? ts.posterURL
: path.join(config.STATIC_PATH, ts.posterURL) : path.join(config.STATIC_PATH, ts.posterURL)
dst = path.join(config.POSTER_PATH, infoHash + extension) dst = path.join(config.POSTER_PATH, infoHash + extension)
// Synchronous FS calls aren't ideal, but probably OK in a migration // Synchronous FS calls aren't ideal, but probably OK in a migration
// that only runs once // that only runs once
if (src !== dst) copyFileSync(src, dst) if (src !== dst) fs.copySync(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,9 +107,7 @@ 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')
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,109 +115,23 @@ 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 = [ var defaultTorrentFiles = [
'6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent', '6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent',
'88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent', '88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent',
'6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent', '6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent',
'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) var fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary)
if (!fileOrFolder) return if (!fileOrFolder) return
try { try {
fs.statSync(fileOrFolder) fs.statSync(fileOrFolder)
} catch (err) { } catch (e) {
// Default torrent with "missing path" error. Clear path. // Default torrent with "missing path" error. Clear path.
delete torrentSummary.path delete torrentSummary.path
} }
}) })
} }
function migrate_0_14_0 (saved) {
saved.torrents.forEach(ts => {
delete ts.defaultPlayFileIndex
})
}
function migrate_0_17_0 (saved) {
// Fix a sad, sad bug that resulted in 100MB+ config.json files
saved.torrents.forEach(ts => {
if (!ts.files) return
ts.files.forEach(file => {
if (!file.audioInfo || !file.audioInfo.picture) return
// This contained a Buffer, which 30x'd in size when serialized to JSON
delete file.audioInfo.picture
})
})
}
function migrate_0_17_2 (saved) {
// Remove the trailing dot (.) from the Wired CD torrent name, since
// folders/files that end in a trailing dot (.) or space are not deletable from
// Windows Explorer. See: https://github.com/webtorrent/webtorrent-desktop/issues/905
const OLD_NAME = 'The WIRED CD - Rip. Sample. Mash. Share.'
const NEW_NAME = 'The WIRED CD - Rip. Sample. Mash. Share'
const OLD_HASH = '3ba219a8634bf7bae3d848192b2da75ae995589d'
const NEW_HASH = 'a88fda5954e89178c372716a6a78b8180ed4dad3'
const ts = saved.torrents.find(ts => ts.infoHash === OLD_HASH)
if (!ts) return // Wired CD torrent does not exist
// New versions of WebTorrent ship with a fixed torrent file. Let's fix up the
// name in existing versions of WebTorrent.
ts.name = ts.displayName = NEW_NAME
ts.files.forEach((file) => {
file.path = file.path.replace(OLD_NAME, NEW_NAME)
})
// Changing the torrent name causes the info hash to change
ts.infoHash = NEW_HASH
ts.magnetURI = ts.magnetURI.replace(OLD_HASH, NEW_HASH)
try {
fs.renameSync(
path.join(config.POSTER_PATH, ts.posterFileName),
path.join(config.POSTER_PATH, NEW_HASH + '.jpg')
)
} catch (err) {}
ts.posterFileName = NEW_HASH + '.jpg'
fs.rmdirSync(path.join(config.TORRENT_PATH, ts.torrentFileName))
copyFileSync(
path.join(config.STATIC_PATH, 'wiredCd.torrent'),
path.join(config.TORRENT_PATH, NEW_HASH + '.torrent')
)
ts.torrentFileName = NEW_HASH + '.torrent'
if (ts.path) {
// If torrent folder already exists on disk, try to rename it
try {
fs.renameSync(
path.join(ts.path, OLD_NAME),
path.join(ts.path, NEW_NAME)
)
} catch (err) {}
}
}
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,81 +0,0 @@
import TorrentSummary from './torrent-summary.js'
import TorrentPlayer from './torrent-player.js'
const cache = {
infoHash: null,
previousIndex: null,
currentIndex: null,
nextIndex: null
}
function hasNext (state) {
updateCache(state)
return cache.nextIndex !== null
}
function getNextIndex (state) {
updateCache(state)
return cache.nextIndex
}
function hasPrevious (state) {
updateCache(state)
return cache.previousIndex !== null
}
function getPreviousIndex (state) {
updateCache(state)
return cache.previousIndex
}
function getCurrentLocalURL (state) {
return state.server
? state.server.localURL + '/' + state.playing.fileIndex + '/' +
encodeURIComponent(state.playing.fileName)
: ''
}
function updateCache (state) {
const infoHash = state.playing.infoHash
const fileIndex = state.playing.fileIndex
if (infoHash === cache.infoHash) {
switch (fileIndex) {
case cache.currentIndex:
return
case cache.nextIndex:
cache.previousIndex = cache.currentIndex
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
return
case cache.previousIndex:
cache.previousIndex = findPreviousIndex(state)
cache.nextIndex = cache.currentIndex
cache.currentIndex = fileIndex
return
}
} else {
cache.infoHash = infoHash
}
cache.previousIndex = findPreviousIndex(state)
cache.currentIndex = fileIndex
cache.nextIndex = findNextIndex(state)
}
function findPreviousIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex - 1; i >= 0; i--) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}
function findNextIndex (state) {
const files = TorrentSummary.getByKey(state, state.playing.infoHash).files
for (let i = state.playing.fileIndex + 1; i < files.length; i++) {
if (TorrentPlayer.isPlayable(files[i])) return i
}
return null
}
export { hasNext }
export { getNextIndex }
export { hasPrevious }
export { getPreviousIndex }
export { getCurrentLocalURL }
export default {
hasNext,
getNextIndex,
hasPrevious,
getPreviousIndex,
getCurrentLocalURL
}

View File

@@ -1,23 +1,28 @@
import config from '../src/config.js' module.exports = {
import { InvalidSoundNameError } from './errors.js' preload,
import path from 'path' play
const VOLUME = 0.25 }
// App state to access the soundNotifications preference
let state var config = require('../../config')
var path = require('path')
var VOLUME = 0.15
/* Cache of Audio elements, for instant playback */ /* Cache of Audio elements, for instant playback */
const cache = {} var cache = {}
const sounds = {
var sounds = {
ADD: { ADD: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
volume: VOLUME volume: VOLUME
}, },
DELETE: { DELETE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
volume: VOLUME * 0.5 volume: VOLUME
}, },
DISABLE: { DISABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
volume: VOLUME * 0.5 volume: VOLUME
}, },
DONE: { DONE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
@@ -25,7 +30,7 @@ const sounds = {
}, },
ENABLE: { ENABLE: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
volume: VOLUME * 0.5 volume: VOLUME
}, },
ERROR: { ERROR: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
@@ -37,24 +42,27 @@ const sounds = {
}, },
STARTUP: { STARTUP: {
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'), url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
volume: VOLUME volume: VOLUME * 2
} }
} }
function init (appState) {
state = appState function preload () {
for (var name in sounds) {
if (!cache[name]) {
var sound = sounds[name]
var audio = cache[name] = new window.Audio()
audio.volume = sound.volume
audio.src = sound.url
}
}
} }
function play (name) { function play (name) {
if (state == null) { var audio = cache[name]
return
}
if (!state.saved.prefs.soundNotifications) {
return
}
let audio = cache[name]
if (!audio) { if (!audio) {
const sound = sounds[name] var sound = sounds[name]
if (!sound) { if (!sound) {
throw new InvalidSoundNameError(name) throw new Error('Invalid sound name')
} }
audio = cache[name] = new window.Audio() audio = cache[name] = new window.Audio()
audio.volume = sound.volume audio.volume = sound.volume
@@ -63,9 +71,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,21 @@
import applicationConfig from 'application-config' var appConfig = require('application-config')('WebTorrent')
import path from 'path' var path = require('path')
import { EventEmitter } from 'events' var {EventEmitter} = require('events')
import config from '../../config.js' var config = require('../../config')
var migrations = require('./migrations')
const appConfig = applicationConfig('WebTorrent') var State = module.exports = Object.assign(new EventEmitter(), {
const SAVE_DEBOUNCE_INTERVAL = 1000 getDefaultPlayState,
load,
save,
saveThrottled
})
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json') appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
const State = Object.assign(new EventEmitter(), { function getDefaultState () {
getDefaultPlayState, var LocationHistory = require('location-history')
load,
// state.save() calls are rate-limited. Use state.saveImmediate() to skip limit.
async save (...args) {
// Perf optimization: Lazy-require debounce (and it's dependencies)
const debounce = await import('debounce')
// After first State.save() invokation, future calls go straight to the
// debounced function
State.save = debounce(saveImmediate, SAVE_DEBOUNCE_INTERVAL)
State.save(...args)
},
saveImmediate
})
export default State
async function getDefaultState () {
const { default: LocationHistory } = await import('location-history')
return { return {
/* /*
@@ -80,9 +68,7 @@ async function getDefaultState () {
* Getters, for convenience * Getters, for convenience
*/ */
getPlayingTorrentSummary, getPlayingTorrentSummary,
getPlayingFileSummary, getPlayingFileSummary
getExternalPlayerName,
shouldHidePlayerControls
} }
} }
@@ -91,12 +77,10 @@ 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 */
duration: 1, /* seconds */ duration: 1, /* seconds */
isReady: false,
isPaused: true, isPaused: true,
isStalled: false, isStalled: false,
lastTimeUpdate: 0, /* Unix time in ms */ lastTimeUpdate: 0, /* Unix time in ms */
@@ -108,60 +92,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 setupSavedState (cb) {
const { copyFileSync, mkdirSync, readFileSync } = await import('fs') var fs = require('fs-extra')
const { default: parseTorrent } = await import('parse-torrent') var parseTorrent = require('parse-torrent')
var parallel = require('run-parallel')
const saved = { var 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,
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 var 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 var infoHash = saved.torrents[i].infoHash
// TODO: Doing several sync calls during first startup is not ideal tasks.push(function (cb) {
copyFileSync( fs.copy(
path.join(config.STATIC_PATH, t.posterFileName), path.join(config.STATIC_PATH, t.posterFileName),
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)) path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName)),
) cb
copyFileSync( )
path.join(config.STATIC_PATH, t.torrentFileName), })
path.join(config.TORRENT_PATH, infoHash + '.torrent') tasks.push(function (cb) {
) fs.copy(
path.join(config.STATIC_PATH, t.torrentFileName),
path.join(config.TORRENT_PATH, infoHash + '.torrent'),
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 var torrent = fs.readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
const torrent = readFileSync(path.join(config.STATIC_PATH, t.torrentFileName)) var parsedTorrent = parseTorrent(torrent)
const parsedTorrent = parseTorrent(torrent)
return { return {
status: 'paused', status: 'paused',
@@ -172,87 +151,62 @@ async function setupStateSaved () {
torrentFileName: parsedTorrent.infoHash + '.torrent', torrentFileName: parsedTorrent.infoHash + '.torrent',
magnetURI: parseTorrent.toMagnetURI(parsedTorrent), magnetURI: parseTorrent.toMagnetURI(parsedTorrent),
files: parsedTorrent.files, files: parsedTorrent.files,
selections: parsedTorrent.files.map((x) => true), selections: parsedTorrent.files.map((x) => true)
testID: t.testID
} }
} }
} }
function getPlayingTorrentSummary () { function getPlayingTorrentSummary () {
const infoHash = this.playing.infoHash var infoHash = this.playing.infoHash
return this.saved.torrents.find((x) => x.infoHash === infoHash) return this.saved.torrents.find((x) => x.infoHash === infoHash)
} }
function getPlayingFileSummary () { function getPlayingFileSummary () {
const torrentSummary = this.getPlayingTorrentSummary() var torrentSummary = this.getPlayingTorrentSummary()
if (!torrentSummary) return null if (!torrentSummary) return null
return torrentSummary.files[this.playing.fileIndex] return torrentSummary.files[this.playing.fileIndex]
} }
function getExternalPlayerName () { function load (cb) {
const playerPath = this.saved.prefs.externalPlayerPath var state = getDefaultState()
if (!playerPath) return 'VLC'
return path.basename(playerPath).split('.')[0]
}
function shouldHidePlayerControls () { appConfig.read(function (err, saved) {
return this.location.url() === 'player' && if (err || !saved.version) {
this.playing.mouseStationarySince !== 0 && console.log('Missing config file: Creating new one')
new Date().getTime() - this.playing.mouseStationarySince > 2000 && setupSavedState(onSaved)
!this.playing.mouseInControls && } else {
!this.playing.isPaused && onSaved(null, saved)
this.playing.location === 'local' &&
this.playing.playbackRate === 1
}
async function load (cb) {
console.log('state load')
let saved = await appConfig.read()
if (!saved || !saved.version) {
console.log('Missing config file: Creating new one')
try {
saved = await setupStateSaved()
} catch (err) {
onSavedState(err)
return
} }
} })
onSavedState(null, saved) function onSaved (err, saved) {
async function onSavedState (err, saved) {
if (err) return cb(err) if (err) return cb(err)
const state = await getDefaultState()
state.saved = saved state.saved = saved
migrations.run(state)
if (process.type === 'renderer') {
// Perf optimization: Save require() calls in the main process
const migrations = require('./migrations')
migrations.run(state)
}
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 save (state, cb) {
console.log('Saving state to ' + appConfig.filePath) console.log('Saving state to ' + appConfig.filePath)
delete state.saveStateTimeout
// Clean up, so that we're not saving any pending state // Clean up, so that we're not saving any pending state
const copy = Object.assign({}, state.saved) var copy = Object.assign({}, state.saved)
// Remove torrents pending addition to the list, where we haven't finished // Remove torrents pending addition to the list, where we haven't finished
// reading the torrent file or file(s) to seed & don't have an infohash // reading the torrent file or file(s) to seed & don't have an infohash
copy.torrents = copy.torrents copy.torrents = copy.torrents
.filter((x) => x.infoHash) .filter((x) => x.infoHash)
.map(x => { .map(function (x) {
const torrent = {} var torrent = {}
for (const key in x) { for (var key in x) {
if (key === 'progress' || key === 'torrentKey') { if (key === 'progress' || key === 'torrentKey') {
continue // Don't save progress info or key for the webtorrent process continue // Don't save progress info or key for the webtorrent process
} }
if (key === 'playStatus') {
continue // Don't save whether a torrent is playing / pending
}
if (key === 'error') { if (key === 'error') {
continue // Don't save error states continue // Don't save error states
} }
@@ -261,10 +215,17 @@ 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('savedState')
} catch (err) { })
console.error(err) }
}
// Write, but no more than once a second
function saveThrottled (state) {
if (state.saveStateTimeout) return
state.saveStateTimeout = setTimeout(function () {
if (!state.saveStateTimeout) return
save(state)
}, 1000)
} }

View File

@@ -1,53 +1,45 @@
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 = {
let telemetry init,
logUncaughtError,
async function init (state) { logPlayAttempt
telemetry = state.saved.telemetry
// First app run
if (!telemetry) {
const crypto = await import('crypto')
telemetry = state.saved.telemetry = {
userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID
}
reset()
}
} }
async function send (state) { const crypto = require('crypto')
const now = new Date() const electron = require('electron')
const https = require('https')
const os = require('os')
const url = require('url')
const config = require('../../config')
var telemetry
function init (state) {
telemetry = state.saved.telemetry
if (!telemetry) {
telemetry = state.saved.telemetry = createSummary()
reset()
}
var now = new Date()
telemetry.version = config.APP_VERSION telemetry.version = config.APP_VERSION
telemetry.timestamp = now.toISOString() telemetry.timestamp = now.toISOString()
telemetry.localTime = now.toTimeString() telemetry.localTime = now.toTimeString()
telemetry.screens = getScreenInfo() telemetry.screens = getScreenInfo()
telemetry.system = getSystemInfo() telemetry.system = getSystemInfo()
telemetry.torrentStats = getTorrentStats(state) telemetry.approxNumTorrents = getApproxNumTorrents(state)
telemetry.approxNumTorrents = telemetry.torrentStats.approxCount
if (!config.IS_PRODUCTION) { if (config.IS_PRODUCTION) {
postToServer()
// If the user keeps WebTorrent running for a long time, post every 12h
setInterval(postToServer, 12 * 3600 * 1000)
} else {
// Development: telemetry used only for local debugging // Development: telemetry used only for local debugging
// Empty uncaught errors, etc at the start of every run // Empty uncaught errors, etc at the start of every run
return reset()
}
const get = await import('simple-get')
const opts = {
url: config.TELEMETRY_URL,
body: telemetry,
json: true
}
get.post(opts, (err, res) => {
if (err) return console.error('Error sending telemetry', err)
if (res.statusCode !== 200) {
return console.error(`Error sending telemetry, status code: ${res.statusCode}`)
}
console.log('Sent telemetry')
reset() reset()
}) }
} }
function reset () { function reset () {
@@ -56,15 +48,50 @@ function reset () {
minVersion: config.APP_VERSION, minVersion: config.APP_VERSION,
total: 0, total: 0,
success: 0, success: 0,
timeout: 0,
error: 0, error: 0,
external: 0,
abandoned: 0 abandoned: 0
} }
} }
function postToServer () {
// Serialize the telemetry summary
var payload = new Buffer(JSON.stringify(telemetry), 'utf8')
// POST to our server
var options = url.parse(config.TELEMETRY_URL)
options.method = 'POST'
options.headers = {
'Content-Type': 'application/json',
'Content-Length': payload.length
}
var req = https.request(options, function (res) {
if (res.statusCode === 200) {
console.log('Successfully posted telemetry summary')
reset()
} else {
console.error('Couldn\'t post telemetry summary, got HTTP ' + res.statusCode)
}
})
req.on('error', function (e) {
console.error('Couldn\'t post telemetry summary', e)
})
req.write(payload)
req.end()
}
// Creates a new telemetry summary. Gives the user a unique ID,
// collects screen resolution, etc
function createSummary () {
// Make a 256-bit random unique ID
var userID = crypto.randomBytes(32).toString('hex')
return { userID }
}
// Track screen resolution // Track screen resolution
function getScreenInfo () { function getScreenInfo () {
return 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,69 +99,23 @@ 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')
return { return {
osPlatform: process.platform, osPlatform: process.platform,
osRelease: os.type() + ' ' + os.release(), osRelease: os.type() + ' ' + os.release(),
architecture: os.arch(), architecture: os.arch(),
systemArchitecture: config.OS_SYSARCH, totalMemoryMB: os.totalmem() / (1 << 20),
totalMemoryMB: roundPow2(os.totalmem() / (1 << 20)),
numCores: os.cpus().length numCores: os.cpus().length
} }
} }
// Get stats like the # of torrents currently active, # in list, total size // Get the number of torrents, rounded to the nearest power of two
function getTorrentStats (state) { function getApproxNumTorrents (state) {
const count = state.saved.torrents.length var exactNum = state.saved.torrents.length
let sizeMB = 0 if (exactNum === 0) return 0
const byStatus = {
new: { count: 0, sizeMB: 0 },
downloading: { count: 0, sizeMB: 0 },
seeding: { count: 0, sizeMB: 0 },
paused: { count: 0, sizeMB: 0 }
}
// First, count torrents & total file size
for (const torrent of state.saved.torrents) {
const stat = byStatus[torrent.status]
if (!torrent || !torrent.files || !stat) continue
stat.count++
for (const file of torrent.files) {
if (!file || !file.length) continue
const fileSizeMB = file.length / (1 << 20)
sizeMB += fileSizeMB
stat.sizeMB += fileSizeMB
}
}
// Then, round all the counts and sums to the nearest power of 2
const ret = roundTorrentStats({ count, sizeMB })
ret.byStatus = {
new: roundTorrentStats(byStatus.new),
downloading: roundTorrentStats(byStatus.downloading),
seeding: roundTorrentStats(byStatus.seeding),
paused: roundTorrentStats(byStatus.paused)
}
return ret
}
function roundTorrentStats (stats) {
return {
approxCount: roundPow2(stats.count),
approxSizeMB: roundPow2(stats.sizeMB)
}
}
// Rounds to the nearest power of 2, for privacy and easy bucketing.
// Rounds 35 to 32, 70 to 64, 5 to 4, 1 to 1, 0 to 0.
// Supports nonnegative numbers only.
function roundPow2 (n) {
if (n <= 0) return 0
// Otherwise, return 1, 2, 4, 8, etc by rounding in log space // Otherwise, return 1, 2, 4, 8, etc by rounding in log space
const log2 = Math.log(n) / Math.log(2) var log2 = Math.log(exactNum) / Math.log(2)
return 2 ** Math.round(log2) return 1 << 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
@@ -143,10 +124,12 @@ function logUncaughtError (procName, e) {
// Hopefully uncaught errors immediately on startup are fixed in dev // Hopefully uncaught errors immediately on startup are fixed in dev
if (!telemetry) return if (!telemetry) return
let message var message
let stack = '' var stack = ''
if (e == null) { if (e.message) {
message = 'Unexpected undefined error' // err is either an Error or a plain object {message, stack}
message = e.message
stack = e.stack
} else if (e.error) { } else if (e.error) {
// Uncaught Javascript errors (window.onerror), err is an ErrorEvent // Uncaught Javascript errors (window.onerror), err is an ErrorEvent
if (!e.error.message) { if (!e.error.message) {
@@ -155,10 +138,6 @@ function logUncaughtError (procName, e) {
message = e.error.message message = e.error.message
stack = e.error.stack stack = e.error.stack
} }
} else if (e.message) {
// err is either an Error or a plain object {message, stack}
message = e.message
stack = e.stack
} else { } else {
// Resource errors (captured element.onerror), err is an Event // Resource errors (captured element.onerror), err is an Event
if (!e.target) { if (!e.target) {
@@ -170,14 +149,11 @@ function logUncaughtError (procName, e) {
} }
} }
if (typeof stack !== 'string') stack = 'Unexpected stack: ' + stack
if (typeof message !== 'string') message = 'Unexpected message: ' + message
// Remove the first part of each file path in the stack trace. // Remove the first part of each file path in the stack trace.
// - Privacy: remove personal info like C:\Users\<full name> // - Privacy: remove personal info like C:\Users\<full name>
// - Aggregation: this lets us find which stacktraces occur often // - Aggregation: this lets us find which stacktraces occur often
stack = stack.replace(/\(.*app.asar/g, '(...') if (stack && typeof stack === 'string') stack = stack.replace(/\(.*app.asar/g, '(...')
stack = stack.replace(/at .*app.asar/g, 'at ...') else if (stack) stack = 'Unexpected stack: ' + stack
// We need to POST the telemetry object, make sure it stays < 100kb // We need to POST the telemetry object, make sure it stays < 100kb
if (telemetry.uncaughtErrors.length > 20) return if (telemetry.uncaughtErrors.length > 20) return
@@ -185,39 +161,28 @@ function logUncaughtError (procName, e) {
if (stack.length > 1000) stack = stack.substring(0, 1000) if (stack.length > 1000) stack = stack.substring(0, 1000)
// 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 var 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"
function getElemString (elem) { function getElemString (elem) {
let ret = elem.tagName var ret = elem.tagName
try { try {
ret += '.' + Array.from(elem.classList).join('.') ret += '.' + Array.from(elem.classList).join('.')
} catch (err) {} } catch (e) {}
return ret return ret
} }
// The user pressed play. Did it work, display an error, // The user pressed play. It either worked, timed out, or showed the
// open an external player or did user abandon the attempt? // 'Play in VLC' codec error
function logPlayAttempt (result) { function logPlayAttempt (result) {
if (!['success', 'error', 'external', 'abandoned'].includes(result)) { if (!['success', 'timeout', 'error', 'abandoned'].includes(result)) {
return console.error('Unknown play attempt result', result) return console.error('Unknown play attempt result', result)
} }
const attempts = telemetry.playAttempts var attempts = telemetry.playAttempts
attempts.total = (attempts.total || 0) + 1 attempts.total = (attempts.total || 0) + 1
attempts[result] = (attempts[result] || 0) + 1 attempts[result] = (attempts[result] || 0) + 1
} }
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,84 @@
import path from 'path' module.exports = {
import mediaExtensions from './media-extensions.js' isPlayable,
isVideo,
isAudio,
isTorrent,
isPlayableTorrentSummary,
pickFileToPlay
}
var 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) var isTorrentFile = getFileExtension(file) === '.torrent'
} var 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 var 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 } // Picks the default file to play from a list of torrent or torrentSummary files
export { isAudio } // Returns an index or undefined, if no files are playable
export { isTorrent } function pickFileToPlay (files) {
export { isMagnetLink } // first, try to find the biggest video file
export { isPlayableTorrentSummary } var videoFiles = files.filter(isVideo)
export default { if (videoFiles.length > 0) {
isPlayable, var largestVideoFile = videoFiles.reduce(function (a, b) {
isVideo, return a.length > b.length ? a : b
isAudio, })
isTorrent, return files.indexOf(largestVideoFile)
isMagnetLink, }
isPlayableTorrentSummary
// if there are no videos, play the first audio file
var audioFiles = files.filter(isAudio)
if (audioFiles.length > 0) {
return files.indexOf(audioFiles[0])
}
// no video or audio means nothing is playable
return undefined
} }

View File

@@ -1,151 +1,83 @@
import captureFrame from 'capture-frame' module.exports = torrentPoster
import path from 'path'
import mediaExtensions from './media-extensions.js' var captureVideoFrame = require('./capture-video-frame')
const msgNoSuitablePoster = 'Cannot generate a poster from any files in the torrent' var 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] var 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) { var 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 var 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) var files = torrent.files.filter(function (file) {
if (files.length === 0) return undefined var extname = path.extname(file.name).toLowerCase()
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()
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) var index = torrent.files.indexOf(file)
const server = torrent.createServer(0)
var server = torrent.createServer(0)
server.listen(0, onListening) server.listen(0, onListening)
function onListening () { function onListening () {
const port = server.address().port var port = server.address().port
const url = 'http://localhost:' + port + '/' + index var url = 'http://localhost:' + port + '/' + index
const video = document.createElement('video') var 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 var buf = captureVideoFrame(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) var extname = path.extname(file.name)
}
function extractPoster (file, cb) {
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,56 +1,57 @@
import path from 'path' module.exports = {
import config from '../src/config.js' getPosterPath,
getTorrentPath,
getByKey,
getTorrentID,
getFileOrFolder
}
var path = require('path')
var 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) {
if (!torrentSummary || !torrentSummary.posterFileName) return null if (!torrentSummary || !torrentSummary.posterFileName) return null
const posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName) var posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName)
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron): // Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
// Backslashes in URLS in CSS cause bizarre string encoding issues // Backslashes in URLS in CSS cause bizarre string encoding issues
return posterPath.replace(/\\/g, '/') return posterPath.replace(/\\/g, '/')
} }
// 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) {
const s = torrentSummary var s = torrentSummary
if (s.torrentFileName) { // Load torrent file from disk if (s.torrentFileName) { // Load torrent file from disk
return getTorrentPath(s) return getTorrentPath(s)
} else { // Load torrent from DHT } else { // Load torrent from DHT
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.
// TODO: make this assumption explicit, enforce it in the `create-torrent` // TODO: make this assumption explicit, enforce it in the `create-torrent`
// module. Store root folder explicitly to avoid hacky path processing below. // module. Store root folder explicitly to avoid hacky path processing below.
function getFileOrFolder (torrentSummary) { function getFileOrFolder (torrentSummary) {
const ts = torrentSummary var ts = torrentSummary
if (!ts.path || !ts.files || ts.files.length === 0) return null if (!ts.path || !ts.files || ts.files.length === 0) return null
const dirname = ts.files[0].path.split(path.sep)[0] return path.join(ts.path, ts.files[0].path.split('/')[0])
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,86 +1,73 @@
/* eslint-disable import/first */
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' const dragDrop = require('drag-drop')
State.load(onState) const electron = require('electron')
const fs = require('fs')
const React = require('react')
const ReactDOM = require('react-dom')
import createGetter from 'fn-getter' const config = require('../config')
import remote from '@electron/remote' const telemetry = require('./lib/telemetry')
import debounce from 'debounce' const sound = require('./lib/sound')
import dragDrop from 'drag-drop' const State = require('./lib/state')
import electron from 'electron' const TorrentPlayer = require('./lib/torrent-player')
import fs from 'fs'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import config from '../config.js' // Required by Material UI -- adds `onTouchTap` event
import telemetry from './lib/telemetry.js' require('react-tap-event-plugin')()
import sound from './lib/sound.js'
import TorrentPlayer from './lib/torrent-player.js'
import App from './pages/app.js'
import { setDispatch } from './lib/dispatcher.js'
import TorrentController from './controllers/torrent-controller.js' const App = require('./pages/App')
import AudioTracksController from './controllers/audio-tracks-controller.js'
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 const MediaController = require('./controllers/media-controller')
const UpdateController = require('./controllers/update-controller')
const PrefsController = require('./controllers/prefs-controller')
const PlaybackController = require('./controllers/playback-controller')
const SubtitlesController = require('./controllers/subtitles-controller')
const TorrentListController = require('./controllers/torrent-list-controller')
const TorrentController = require('./controllers/torrent-controller')
// Yo-yo pattern: state object lives here and percolates down thru all the views. // 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 var controllers = null
// This dependency is the slowest-loading, so we lazy load it // This dependency is the slowest-loading, so we lazy load it
let Cast = null var Cast = null
// Electron apps have two processes: a main process (node) runs first and starts
// a renderer process (essentially a Chrome window). We're in the renderer process,
// and this IPC channel receives from and sends messages to the main process
var ipcRenderer = electron.ipcRenderer
// All state lives in state.js. `state.saved` is read from and written to a file. // All state lives in state.js. `state.saved` is read from and written to a file.
// All other state is ephemeral. First we load state.saved then initialize the app. // All other state is ephemeral. First we load state.saved then initialize the app.
let state var state
// Root React component // Root React component
let app var app
State.load(onState)
// Called once when the application loads. (Not once per window.) // Called once when the application loads. (Not once per window.)
// Connects to the torrent networks, sets up the UI and OS integrations like // Connects to the torrent networks, sets up the UI and OS integrations like
// the dock icon and drag+drop. // the dock icon and drag+drop.
function onState (err, _state) { function onState (err, _state) {
if (err) return onError(err) if (err) return onError(err)
state = window.state = _state // Make available for easier debugging
// Make available for easier debugging
state = window.state = _state
window.dispatch = dispatch
telemetry.init(state)
sound.init(state)
// Log uncaught JS errors
window.addEventListener(
'error', (e) => telemetry.logUncaughtError('window', e), true /* capture */
)
// Create controllers // Create controllers
controllers = { controllers = {
media: createGetter(() => new MediaController(state)), media: new MediaController(state),
playback: createGetter(() => new PlaybackController(state, config, update)), update: new UpdateController(state),
prefs: createGetter(() => new PrefsController(state, config)), prefs: new PrefsController(state, config),
subtitles: createGetter(() => new SubtitlesController(state)), playback: new PlaybackController(state, config, update),
audioTracks: createGetter(() => new AudioTracksController(state)), subtitles: new SubtitlesController(state),
torrent: createGetter(() => new TorrentController(state)), torrentList: new TorrentListController(state),
torrentList: createGetter(() => new TorrentListController(state)), torrent: new TorrentController(state)
update: createGetter(() => new UpdateController(state)),
folderWatcher: createGetter(() => new FolderWatcherController())
} }
// Add first page to location history // Add first page to location history
@@ -95,21 +82,23 @@ function onState (err, _state) {
// Restart everything we were torrenting last time the app ran // Restart everything we were torrenting last time the app ran
resumeTorrents() resumeTorrents()
// Initialize ReactDOM
ReactDOM.render(
<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
// a progress bar and to keep the cursor in sync when playing a video // a progress bar and to keep the cursor in sync when playing a video
setInterval(update, 1000) setInterval(update, 1000)
app = ReactDOM.render(<App state={state} />, document.querySelector('#body'))
// Lazy-load other stuff, like the AppleTV module, later to keep startup fast
window.setTimeout(delayedInit, config.DELAYED_INIT)
// Listen for messages from the main process // Listen for messages from the main process
setupIpc() setupIpc()
// Drag and drop files/text to start torrenting or seeding // Warn if the download dir is gone, eg b/c an external drive is unplugged
checkDownloadPath()
// OS integrations:
// ...drag and drop files/text to start torrenting or seeding
dragDrop('body', { dragDrop('body', {
onDrop: onOpen, onDrop: onOpen,
onDropText: onOpen onDropText: onOpen
@@ -118,58 +107,34 @@ 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()) { // ...window visibility state.
sound.play('STARTUP') document.addEventListener('webkitvisibilitychange', onVisibilityChange)
}
// To keep app startup fast, some code is delayed. // Log uncaught JS errors
window.setTimeout(delayedInit, config.DELAYED_INIT) window.addEventListener('error',
(e) => telemetry.logUncaughtError('window', e),
true /* capture */)
// Done! Ideally we want to get here < 500ms after the user clicks the app // Done! Ideally we want to get here < 500ms after the user clicks the app
sound.play('STARTUP')
console.timeEnd('init') console.timeEnd('init')
} }
// Runs a few seconds after the app loads, to avoid slowing down startup time // Runs a few seconds after the app loads, to avoid slowing down startup time
function delayedInit () { function delayedInit () {
telemetry.send(state)
// Send telemetry data every 12 hours, for users who keep the app running
// for extended periods of time
setInterval(() => telemetry.send(state), 12 * 3600 * 1000)
// Warn if the download dir is gone, eg b/c an external drive is unplugged
checkDownloadPath()
// ...window visibility state.
document.addEventListener('webkitvisibilitychange', onVisibilityChange)
onVisibilityChange()
lazyLoadCast() lazyLoadCast()
sound.preload()
telemetry.init(state)
} }
// Lazily loads Chromecast and Airplay support // Lazily loads Chromecast and Airplay support
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
@@ -181,7 +146,7 @@ async function lazyLoadCast () {
// 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller // 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller
// 4. controller - the controller handles the event, changing the state object // 4. controller - the controller handles the event, changing the state object
function update () { function update () {
controllers.playback().showOrHidePlayerControls() controllers.playback.showOrHidePlayerControls()
app.setState(state) app.setState(state)
updateElectron() updateElectron()
} }
@@ -205,111 +170,82 @@ 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(), 'toggleTorrentFile': (infoHash, index) => controllers.torrentList.toggleTorrentFile(infoHash, index),
resumeAllTorrents: () => controllers.torrentList().resumeAllTorrents(), 'confirmDeleteTorrent': (infoHash, deleteData) => controllers.torrentList.confirmDeleteTorrent(infoHash, deleteData),
toggleTorrentFile: (infoHash, index) => 'deleteTorrent': (infoHash, deleteData) => controllers.torrentList.deleteTorrent(infoHash, deleteData),
controllers.torrentList().toggleTorrentFile(infoHash, index), 'toggleSelectTorrent': (infoHash) => controllers.torrentList.toggleSelectTorrent(infoHash),
confirmDeleteTorrent: (infoHash, deleteData) => 'openTorrentContextMenu': (infoHash) => controllers.torrentList.openTorrentContextMenu(infoHash),
controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData), 'startTorrentingSummary': (torrentKey) => controllers.torrentList.startTorrentingSummary(torrentKey),
deleteTorrent: (infoHash, deleteData) =>
controllers.torrentList().deleteTorrent(infoHash, deleteData),
openTorrentListContextMenu: () => onPaste(),
confirmDeleteAllTorrents: (deleteData) =>
controllers.torrentList().confirmDeleteAllTorrents(deleteData),
deleteAllTorrents: (deleteData) =>
controllers.torrentList().deleteAllTorrents(deleteData),
toggleSelectTorrent: (infoHash) =>
controllers.torrentList().toggleSelectTorrent(infoHash),
openTorrentContextMenu: (infoHash) =>
controllers.torrentList().openTorrentContextMenu(infoHash),
startTorrentingSummary: (torrentKey) =>
controllers.torrentList().startTorrentingSummary(torrentKey),
saveTorrentFileAs: (torrentKey) =>
controllers.torrentList().saveTorrentFileAs(torrentKey),
prioritizeTorrent: (infoHash) => controllers.torrentList().prioritizeTorrent(infoHash),
resumePausedTorrents: () => controllers.torrentList().resumePausedTorrents(),
// Playback // Playback
playFile: (infoHash, index) => controllers.playback().playFile(infoHash, index), 'playFile': (infoHash, index) => controllers.playback.playFile(infoHash, index),
playPause: () => controllers.playback().playPause(), 'playPause': () => controllers.playback.playPause(),
nextTrack: () => controllers.playback().nextTrack(), 'skip': (time) => controllers.playback.skip(time),
previousTrack: () => controllers.playback().previousTrack(), 'skipTo': (time) => controllers.playback.skipTo(time),
skip: (time) => controllers.playback().skip(time), 'changePlaybackRate': (dir) => controllers.playback.changePlaybackRate(dir),
skipTo: (time) => controllers.playback().skipTo(time), 'changeVolume': (delta) => controllers.playback.changeVolume(delta),
preview: (x) => controllers.playback().preview(x), 'setVolume': (vol) => controllers.playback.setVolume(vol),
clearPreview: () => controllers.playback().clearPreview(), 'openItem': (infoHash, index) => controllers.playback.openItem(infoHash, index),
changePlaybackRate: (dir) => controllers.playback().changePlaybackRate(dir),
changeVolume: (delta) => controllers.playback().changeVolume(delta),
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(), 'openExternalPlayer': () => controllers.media.openExternalPlayer(),
mediaControlsMouseLeave: () => controllers.media().controlsMouseLeave(), 'externalPlayerNotFound': () => controllers.media.externalPlayerNotFound(),
openExternalPlayer: () => controllers.media().openExternalPlayer(),
externalPlayerNotFound: () => controllers.media().externalPlayerNotFound(),
// Remote casting: Chromecast, Airplay, etc // Remote casting: Chromecast, Airplay, etc
toggleCastMenu: (deviceType) => lazyLoadCast().toggleMenu(deviceType), 'toggleCastMenu': (deviceType) => lazyLoadCast().toggleMenu(deviceType),
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), 'saveState': () => State.save(state),
stateSaveImmediate: () => State.saveImmediate(state), 'saveStateThrottled': () => State.saveThrottled(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()
@@ -319,13 +255,13 @@ function dispatch (action, ...args) {
console.log('dispatch: %s %o', action, args) console.log('dispatch: %s %o', action, args)
} }
const handler = dispatchHandlers[action] var handler = dispatchHandlers[action]
if (handler) handler(...args) if (handler) handler(...args)
else console.error('Missing dispatch handler: ' + action) else console.error('Missing dispatch handler: ' + action)
// Update the virtual DOM, unless it's just a mouse move event // Update the virtual DOM, unless it's just a mouse move event
if (action !== 'mediaMouseMoved' || if (action !== 'mediaMouseMoved' ||
controllers.playback().showOrHidePlayerControls()) { controllers.playback.showOrHidePlayerControls()) {
update() update()
} }
} }
@@ -338,13 +274,11 @@ function setupIpc () {
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args)) ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
ipcRenderer.on('fullscreenChanged', onFullscreenChanged) ipcRenderer.on('fullscreenChanged', onFullscreenChanged)
ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged)
const tc = controllers.torrent() var 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))
@@ -359,16 +293,16 @@ function setupIpc () {
ipcRenderer.send('ipcReady') ipcRenderer.send('ipcReady')
State.on('stateSaved', () => ipcRenderer.send('stateSaved')) State.on('savedState', () => ipcRenderer.send('savedState'))
} }
// Quits any modal popovers and returns to the torrent list screen // Quits any modal popovers and returns to the torrent list screen
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') var contentTag = document.querySelector('.content')
if (contentTag) contentTag.scrollTop = 0 if (contentTag) contentTag.scrollTop = 0
}) })
} }
@@ -394,13 +328,13 @@ function resumeTorrents () {
return torrentSummary return torrentSummary
}) })
.filter((s) => s.status !== 'paused') .filter((s) => s.status !== 'paused')
.forEach((s) => controllers.torrentList().startTorrentingSummary(s.torrentKey)) .forEach((s) => controllers.torrentList.startTorrentingSummary(s.torrentKey))
} }
// Set window dimensions to match video dimensions or fill the screen // Set window dimensions to match video dimensions or fill the screen
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,58 +346,51 @@ 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 var screenWidth = window.screen.width
const screenHeight = window.screen.height var screenHeight = window.screen.height
const aspectRatio = dimensions.width / dimensions.height var aspectRatio = dimensions.width / dimensions.height
const scaleFactor = Math.min( var scaleFactor = Math.min(
Math.min(screenWidth / dimensions.width, 1), Math.min(screenWidth / dimensions.width, 1),
Math.min(screenHeight / dimensions.height, 1) Math.min(screenHeight / dimensions.height, 1)
) )
const width = Math.max( var width = Math.max(
Math.floor(dimensions.width * scaleFactor), Math.floor(dimensions.width * scaleFactor),
config.WINDOW_MIN_WIDTH config.WINDOW_MIN_WIDTH
) )
const height = Math.max( var height = Math.max(
Math.floor(dimensions.height * scaleFactor), Math.floor(dimensions.height * scaleFactor),
config.WINDOW_MIN_HEIGHT config.WINDOW_MIN_HEIGHT
) )
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" var url = state.location.url()
// this is a sanitization var allTorrents = files.every(TorrentPlayer.isTorrent)
files = files.map(file => { var allSubtitles = files.every(controllers.subtitles.isSubtitle)
if (typeof file !== 'string') return file
return file.replace(/^magnet:\/+\?/i, 'magnet:?')
})
const url = state.location.url()
const allTorrents = files.every(TorrentPlayer.isTorrent)
const allSubtitles = files.every(controllers.subtitles().isSubtitle)
if (allTorrents) { if (allTorrents) {
// Drop torrents onto the app: go to home screen, add torrents, no matter what // Drop torrents onto the app: go to home screen, add torrents, no matter what
dispatch('backToList') dispatch('backToList')
// All .torrent files? Add them. // All .torrent files? Add them.
files.forEach((file) => controllers.torrentList().addTorrent(file)) files.forEach((file) => controllers.torrentList.addTorrent(file))
} else if (url === 'player' && allSubtitles) { } else if (url === 'player' && allSubtitles) {
// Drop subtitles onto a playing video: add subtitles // Drop subtitles onto a playing video: add subtitles
controllers.subtitles().addSubtitles(files, true) controllers.subtitles.addSubtitles(files, true)
} else if (url === 'home') { } else if (url === 'home') {
// Drop files onto home screen: show Create Torrent // Drop files onto home screen: show Create Torrent
state.modal = null state.modal = null
controllers.torrentList().showCreateTorrent(files) controllers.torrentList.showCreateTorrent(files)
} else { } else {
// Drop files onto any other screen: show error // Drop files onto any other screen: show error
return onError('Please go back to the torrent list before creating a new torrent.') return onError('Please go back to the torrent list before creating a new torrent.')
@@ -483,42 +410,15 @@ function onError (err) {
update() update()
} }
const editableHtmlTags = new Set(['input', 'textarea'])
function onPaste (e) { function onPaste (e) {
if (e && editableHtmlTags.has(e.target.tagName.toLowerCase())) return if (e.target.tagName.toLowerCase() === 'input') return
controllers.torrentList().addTorrent(clipboard.readText())
update() var torrentIds = electron.clipboard.readText().split('\n')
} torrentIds.forEach(function (torrentId) {
torrentId = torrentId.trim()
function onKeydown (e) { if (torrentId.length === 0) return
// prevent event fire on user input elements controllers.torrentList.addTorrent(torrentId)
if (editableHtmlTags.has(e.target.tagName.toLowerCase())) return })
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 +435,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) {
@@ -548,15 +448,8 @@ function onFullscreenChanged (e, isFullScreen) {
update() update()
} }
function onWindowBoundsChanged (e, newBounds) {
if (state.location.url() !== 'player') {
state.saved.bounds = newBounds
dispatch('stateSave')
}
}
function checkDownloadPath () { function checkDownloadPath () {
fs.stat(state.saved.prefs.downloadPath, (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)

111
src/renderer/pages/App.js Normal file
View File

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

View File

@@ -0,0 +1,171 @@
const createTorrent = require('create-torrent')
const path = require('path')
const prettyBytes = require('prettier-bytes')
const React = require('react')
const {dispatch, dispatcher} = require('../lib/dispatcher')
const FlatButton = require('material-ui/FlatButton').default
const RaisedButton = require('material-ui/RaisedButton').default
const TextField = require('material-ui/TextField').default
const CreateTorrentErrorPage = require('../components/create-torrent-error-page')
const Heading = require('../components/Heading')
const ShowMore = require('../components/ShowMore')
class CreateTorrentPage extends React.Component {
constructor (props) {
super(props)
var state = this.props.state
var info = state.location.current()
// Preprocess: exclude .DS_Store and other dotfiles
var files = info.files
.filter((f) => !f.name.startsWith('.'))
.map((f) => ({name: f.name, path: f.path, size: f.size}))
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
// First, extract the base folder that the files are all in
var pathPrefix = info.folderPath
if (!pathPrefix) {
pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix)
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
pathPrefix = path.dirname(pathPrefix)
}
}
// Sanity check: show the number of files and total size
var numFiles = files.length
var totalBytes = files
.map((f) => f.size)
.reduce((a, b) => a + b, 0)
var torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
// Then, use the name of the base folder (or sole file, for a single file torrent)
// as the default name. Show all files relative to the base folder.
var defaultName, basePath
if (files.length === 1) {
// Single file torrent: /a/b/foo.jpg -> torrent name 'foo.jpg', path '/a/b'
defaultName = files[0].name
basePath = pathPrefix
} else {
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name 'b', path '/a'
defaultName = path.basename(pathPrefix)
basePath = path.dirname(pathPrefix)
}
var maxFileElems = 100
var fileElems = files.slice(0, maxFileElems).map(function (file, i) {
var relativePath = files.length === 0 ? file.name : path.relative(pathPrefix, file.path)
return (<div key={i}>{relativePath}</div>)
})
if (files.length > maxFileElems) {
fileElems.push(<div key='more'>+ {maxFileElems - files.length} more</div>)
}
var trackers = createTorrent.announceList.join('\n')
this.state = {
basePath,
defaultName,
fileElems,
torrentInfo,
trackers
}
}
handleSubmit () {
var announceList = document.querySelector('.torrent-trackers').value
.split('\n')
.map((s) => s.trim())
.filter((s) => s !== '')
var isPrivate = document.querySelector('.torrent-is-private').checked
var comment = document.querySelector('.torrent-comment').value.trim()
var options = {
// We can't let the user choose their own name if we want WebTorrent
// to use the files in place rather than creating a new folder.
// If we ever want to add support for that:
// name: document.querySelector('.torrent-name').value
name: this.state.defaultName,
path: this.state.basePath,
files: this.state.files,
announce: announceList,
private: isPrivate,
comment: comment
}
dispatch('createTorrent', options)
}
render () {
return (
<div className='create-torrent'>
<Heading level={1}>
Create torrent "{this.state.defaultName}"
</Heading>
<div className='torrent-info'>
{this.state.torrentInfo}
</div>
<div className='torrent-attribute'>
<label>Path:</label>
<div className='torrent-attribute'>{this.state.pathPrefix}</div>
</div>
<ShowMore
style={{
marginBottom: 10
}}
hideLabel='Hide advanced settings...'
showLabel='Show advanced settings...'
>
<div key='advanced' className='create-torrent-advanced'>
<div key='private' className='torrent-attribute'>
<label>Private:</label>
<input type='checkbox' className='torrent-is-private' value='torrent-is-private' />
</div>
<Heading level={2}>Trackers:</Heading>
<TextField
className='torrent-trackers'
hintText='Tracker'
multiLine
rows={2}
rowsMax={10}
defaultValue={this.state.trackers}
/>
<div key='comment' className='torrent-attribute'>
<label>Comment:</label>
<textarea className='torrent-attribute torrent-comment' />
</div>
<div key='files' className='torrent-attribute'>
<label>Files:</label>
<div>{this.state.fileElems}</div>
</div>
</div>
</ShowMore>
<div className='float-right'>
<FlatButton
label='Cancel'
style={{
marginRight: 10
}}
onClick={dispatcher('cancel')}
/>
<RaisedButton
label='Create Torrent'
primary
onClick={this.handleSubmit}
/>
</div>
</div>
)
}
}
// Finds the longest common prefix
function findCommonPrefix (a, b) {
for (var i = 0; i < a.length && i < b.length; i++) {
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
}
if (i === a.length) return a
if (i === b.length) return b
return a.substring(0, i)
}
module.exports = CreateTorrentPage

View File

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

View File

@@ -0,0 +1,177 @@
const colors = require('material-ui/styles/colors')
const path = require('path')
const React = require('react')
const Checkbox = require('material-ui/Checkbox').default
const Heading = require('../components/Heading')
const PathSelector = require('../components/PathSelector')
const RaisedButton = require('material-ui/RaisedButton').default
const {dispatch} = require('../lib/dispatcher')
class PreferencesPage extends React.Component {
constructor (props) {
super(props)
this.handleDownloadPathChange =
this.handleDownloadPathChange.bind(this)
this.handleOpenExternalPlayerChange =
this.handleOpenExternalPlayerChange.bind(this)
this.handleExternalPlayerPathChange =
this.handleExternalPlayerPathChange.bind(this)
}
downloadPathSelector () {
return (
<Preference>
<PathSelector
dialog={{
title: 'Select download directory',
properties: [ 'openDirectory' ]
}}
onChange={this.handleDownloadPathChange}
title='Download location'
value={this.props.state.unsaved.prefs.downloadPath}
/>
</Preference>
)
}
handleDownloadPathChange (filePath) {
dispatch('updatePreferences', 'downloadPath', filePath)
}
openExternalPlayerCheckbox () {
return (
<Preference>
<Checkbox
className='control'
checked={!this.props.state.unsaved.prefs.openExternalPlayer}
label={'Play torrent media files using WebTorrent'}
onCheck={this.handleOpenExternalPlayerChange}
/>
</Preference>
)
}
handleOpenExternalPlayerChange (e, isChecked) {
dispatch('updatePreferences', 'openExternalPlayer', !isChecked)
}
externalPlayerPathSelector () {
const playerName = path.basename(
this.props.state.unsaved.prefs.externalPlayerPath || 'VLC'
)
const description = this.props.state.unsaved.prefs.openExternalPlayer
? `Torrent media files will always play in ${playerName}.`
: `Torrent media files will play in ${playerName} if WebTorrent cannot ` +
'play them.'
return (
<Preference>
<p>{description}</p>
<PathSelector
dialog={{
title: 'Select media player app',
properties: [ 'openFile' ]
}}
displayValue={playerName}
onChange={this.handleExternalPlayerPathChange}
title='External player'
value={this.props.state.unsaved.prefs.externalPlayerPath}
/>
</Preference>
)
}
handleExternalPlayerPathChange (filePath) {
if (path.extname(filePath) === '.app') {
// Mac: Use executable in packaged .app bundle
filePath += '/Contents/MacOS/' + path.basename(filePath, '.app')
}
dispatch('updatePreferences', 'externalPlayerPath', filePath)
}
setDefaultAppButton () {
return (
<Preference>
<p>WebTorrent is not currently the default torrent app.</p>
<RaisedButton
className='control'
onClick={this.handleSetDefaultApp}
label='Make WebTorrent the default'
/>
</Preference>
)
}
handleSetDefaultApp () {
window.alert('TODO')
// var isFileHandler = state.unsaved.prefs.isFileHandler
// dispatch('updatePreferences', 'isFileHandler', !isFileHandler)
}
render () {
return (
<div
style={{
color: colors.grey400,
marginLeft: 25,
marginRight: 25
}}
>
<PreferencesSection title='Downloads'>
{this.downloadPathSelector()}
</PreferencesSection>
<PreferencesSection title='Playback'>
{this.openExternalPlayerCheckbox()}
{this.externalPlayerPathSelector()}
</PreferencesSection>
<PreferencesSection title='Default torrent app'>
{this.setDefaultAppButton()}
</PreferencesSection>
</div>
)
}
}
class PreferencesSection extends React.Component {
static get propTypes () {
return {
title: React.PropTypes.string
}
}
render () {
return (
<div
style={{
marginBottom: 25,
marginTop: 25
}}
>
<Heading level={2}>{this.props.title}</Heading>
{this.props.children}
</div>
)
}
}
class Preference extends React.Component {
render () {
return (
<div
style={{
marginBottom: 10
}}
>
{this.props.children}
</div>
)
}
}
module.exports = PreferencesPage

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