Add files
83
AUTHORS.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Authors
|
||||
|
||||
#### Ordered by first contribution.
|
||||
|
||||
- Feross Aboukhadijeh (feross@feross.org)
|
||||
- DC (dcposch@dcpos.ch)
|
||||
- Nate Goldman (nate@ngoldman.me)
|
||||
- Chris Morris (chris@chrismorris.org)
|
||||
- Giuseppe Crinò (giuscri@gmail.com)
|
||||
- Romain Beaumont (romain.rom1@gmail.com)
|
||||
- Dan Flettre (fletd01@yahoo.com)
|
||||
- Liam Gray (liam.r.gray@gmail.com)
|
||||
- Rémi Jouannet (remijouannet@users.noreply.github.com)
|
||||
- Evan Miller (miller.evan815@gmail.com)
|
||||
- Alex (alxmorais8@msn.com)
|
||||
- Diego Rodríguez Baquero (diegorbaquero@gmail.com)
|
||||
- Karlo Luis Martinez Martos (karlo.luis.m@gmail.com)
|
||||
- gabriel (furstenheim@gmail.com)
|
||||
- Rolando Guedes (rolando.guedes@3gnt.net)
|
||||
- Benjamin Tan (demoneaux@gmail.com)
|
||||
- Mathias Rasmussen (mathiasvr@gmail.com)
|
||||
- Sergey Bargamon (sergey@bargamon.ru)
|
||||
- Thomas Watson Steen (w@tson.dk)
|
||||
- anonymlol (anonymlol7@gmail.com)
|
||||
- Gediminas Petrikas (gedas18@gmail.com)
|
||||
- Alberto Miranda (codealchemist@gmail.com)
|
||||
- Adam Gotlib (gotlib.adam+dev@gmail.com)
|
||||
- Rémi Jouannet (remijouannet@gmail.com)
|
||||
- Andrea Tupini (tupini07@gmail.com)
|
||||
- grunjol (grunjol@gmail.com)
|
||||
- Jason Kurian (jasonk92@gmail.com)
|
||||
- Vamsi Krishna Avula (vamsi_ism@outlook.com)
|
||||
- Noam Okman (noamokman@gmail.com)
|
||||
- PurgingPanda (t3ch0wn3r@gmail.com)
|
||||
- Kai Curtis (morecode@kcurtis.com)
|
||||
- Omri Litov (omrilitov@gmail.com)
|
||||
- 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.
|
659
CHANGELOG.md
Normal file
@ -0,0 +1,659 @@
|
||||
# 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/leenkx/leenkxbox/pull/1864) ([feross](https://github.com/feross))
|
||||
- Update the Windows installer loading image [\#1841](https://github.com/leenkx/leenkxbox/pull/1841) ([alxhotel](https://github.com/alxhotel))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix music metadata not showing up [\#1847](https://github.com/leenkx/leenkxbox/pull/1847) ([Borewit](https://github.com/Borewit))
|
||||
- Fix the "Play in VLC" functionality [\#1850](https://github.com/leenkx/leenkxbox/pull/1850) ([Hinara](https://github.com/Hinara))
|
||||
- Prevent shortcuts from activating when user input elements are focused [\#1840](https://github.com/leenkx/leenkxbox/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/leenkx/leenkxbox/issues/1837#issuecomment-729320901)
|
||||
|
||||
### Added
|
||||
|
||||
- Add macOS Notarization [\#1834](https://github.com/leenkx/leenkxbox/pull/1834) ([feross](https://github.com/feross))
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Electron 10 beta [\#1834](https://github.com/leenkx/leenkxbox/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/leenkx/leenkxbox/pull/1694) ([hicom150](https://github.com/hicom150))
|
||||
- Add support for multiple audio tracks [\#1712](https://github.com/leenkx/leenkxbox/pull/1712) ([hicom150](https://github.com/hicom150))
|
||||
- Improve codec unsupported detection [\#1711](https://github.com/leenkx/leenkxbox/pull/1711) ([hicom150](https://github.com/hicom150))
|
||||
- Report when files are being verified [\#1717](https://github.com/leenkx/leenkxbox/pull/1717) ([pR0Ps](https://github.com/pR0Ps))
|
||||
- Support additional audio files: MPEG-Layer-2, Musepack, Matroska audio, WavePack [\#1772](https://github.com/leenkx/leenkxbox/pull/1772)
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Electron 9 [\#1729](https://github.com/leenkx/leenkxbox/pull/1729) [\#1832](https://github.com/leenkx/leenkxbox/issues/1832)
|
||||
- Update to music-metadata 4.8.0 [\#1719](https://github.com/leenkx/leenkxbox/pull/1719) ([Borewit](https://github.com/Borewit))
|
||||
- Update Windows build documentation [\#1715](https://github.com/leenkx/leenkxbox/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/leenkx/leenkxbox/pull/1720) ([mathiasvr](https://github.com/mathiasvr))
|
||||
- Fix electron SUID sandbox error [\#1707](https://github.com/leenkx/leenkxbox/pull/1707) ([hicom150](https://github.com/hicom150))
|
||||
- Fix percentage rounding error [\#1716](https://github.com/leenkx/leenkxbox/pull/1716) ([pR0Ps](https://github.com/pR0Ps))
|
||||
- Fix path-selector in preferences page [\#1702](https://github.com/leenkx/leenkxbox/pull/1702) ([314eter](https://github.com/314eter))
|
||||
- Fix path-selector in preferences page [\#1704](https://github.com/leenkx/leenkxbox/pull/1702) ([mathiasvr](https://github.com/mathiasvr))
|
||||
- Fix: Increase height of 'About' window [\#1737](https://github.com/leenkx/leenkxbox/pull/1737) ([akaustav](https://github.com/akaustav))
|
||||
- Fix "Save Torrent File As..." [\#1743](https://github.com/leenkx/leenkxbox/pull/1743) ([gpatarin](https://github.com/gpatarin))
|
||||
|
||||
## v0.21.0 - 2019-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- Add YouTube style hotkeys [\#1579](https://github.com/leenkx/leenkxbox/pull/1579) ([dsernst](https://github.com/dsernst))
|
||||
- Toggle sound notifications on/off [\#1536](https://github.com/leenkx/leenkxbox/pull/1536) ([adriantombu](https://github.com/adriantombu))
|
||||
- Ability to play MPEG-4 Audio Book \(.m4b\) [\#1450](https://github.com/leenkx/leenkxbox/pull/1450) ([Borewit](https://github.com/Borewit))
|
||||
- Add support for subtitles on Chromecast [\#1165](https://github.com/leenkx/leenkxbox/pull/1165) ([janza](https://github.com/janza))
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Electron 4 [\#1590](https://github.com/leenkx/leenkxbox/pull/1590) ([Borewit](https://github.com/Borewit))
|
||||
- Remove '\(BETA\)' from app window title [\#1562](https://github.com/leenkx/leenkxbox/pull/1562) ([dsernst](https://github.com/dsernst))
|
||||
- Update React (v16) and Material-UI (v0.20) [\#1483](https://github.com/leenkx/leenkxbox/pull/1483) ([mathiasvr](https://github.com/mathiasvr))
|
||||
- Show audio track and disk number [\#1454](https://github.com/leenkx/leenkxbox/pull/1454) ([Borewit](https://github.com/Borewit))
|
||||
- Asynchronous music metadata updates while streaming [\#1449](https://github.com/leenkx/leenkxbox/pull/1449) ([Borewit](https://github.com/Borewit))
|
||||
- If torrent is not private, leave private flag unset [\#1411](https://github.com/leenkx/leenkxbox/pull/1411) ([feross](https://github.com/feross))
|
||||
- Improve audio poster selection: [\#1368](https://github.com/leenkx/leenkxbox/pull/1368) ([Borewit](https://github.com/Borewit))
|
||||
- Save preferences immediately when changed [\#1042](https://github.com/leenkx/leenkxbox/pull/1042) ([Flet](https://github.com/Flet))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure that the minutes field in playback indicator is zero-padded. [\#1506](https://github.com/leenkx/leenkxbox/pull/1506) ([bnjmnt4n](https://github.com/bnjmnt4n))
|
||||
- Bug Fix: Empty Array Reduce [\#1494](https://github.com/leenkx/leenkxbox/pull/1494) ([clujin](https://github.com/clujin))
|
||||
- Fix startup problems [\#1419](https://github.com/leenkx/leenkxbox/pull/1419) ([Borewit](https://github.com/Borewit))
|
||||
- Add back loading spinner for player page. [\#1311](https://github.com/leenkx/leenkxbox/pull/1311) ([bnjmnt4n](https://github.com/bnjmnt4n))
|
||||
- Fix Linux desktop file [\#1309](https://github.com/leenkx/leenkxbox/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
|
||||
|
||||
### Added
|
||||
- Custom external media player
|
||||
- Linux: add system-wide launcher and icons for Debian, including Ubuntu
|
||||
|
||||
### Changed
|
||||
- Telemetry improvements: redact stacktraces, log app version
|
||||
|
||||
### Fixed
|
||||
- Fix playback and download of default torrents ("missing path" error) (#804)
|
||||
- Fix Delete Torrent + Data for newly added magnet links
|
||||
- Fix jumpToTime error (#804)
|
||||
|
||||
## v0.11.0 - 2016-08-19
|
||||
|
||||
### Added
|
||||
- New Preference to "Set WebTorrent as default handler for torrents and magnet links" (#771)
|
||||
- New Preference to "Always play in VLC" (#674)
|
||||
- Check for missing default download path and torrent folders on start up (#776)
|
||||
|
||||
### Changed
|
||||
- Do not automatically set WebTorrent as the default handler for torrents (#771)
|
||||
- Torrents can only be created from the home screen (#770)
|
||||
- Update Electron to 1.3.3 (#772)
|
||||
|
||||
### Fixed
|
||||
- Allow modifying the default tracker list on the Create Torrent page (#775)
|
||||
- Prevent opening multiple stacked Preference windows or Create Torrent windows (#770)
|
||||
- Windows: Player window auto-resize does not match video aspect ratio (#565)
|
||||
- Missing page title on Create Torrent page
|
||||
|
||||
## v0.10.0 - 2016-08-05
|
||||
|
||||
### Added
|
||||
- Drag-and-drop magnet links (selected text) is now supported (#284)
|
||||
- Windows: Add "User Tasks" shortcuts to app icon in Start Menu (#114)
|
||||
- Linux: Show badge count for completed torrent downloads
|
||||
|
||||
### Changed
|
||||
- Change WebTorrent Desktop peer ID prefix to 'WD' to distinguish from WebTorrent in the browser, 'WW' (#688)
|
||||
- Switch UI to React to improve UI rendering speed (#729)
|
||||
- The primary bottleneck was actually `hyperx`, not `virtual-dom`.
|
||||
- Update Electron to 1.3.2 (#738) (#739) (#740) (#747) (#756)
|
||||
- Mac 10.9: Fix the fullscreen button showing
|
||||
- Mac 10.9: Fix window having border
|
||||
- Mac 10.9: Fix occasional crash
|
||||
- Mac: Update Squirrel.Mac to 0.2.1 (fixes situations in which updates would not get applied)
|
||||
- Mac: Fix window not showing in Window menu
|
||||
- Mac: Fix context menu always choosing first item by default
|
||||
- Linux: Fix startup crashes (some Linux distros)
|
||||
- Linux: Fix menubar not hiding after entering fullscreen (some Linux distros)
|
||||
- Improved location history (back/forward buttons) to fix rare exceptions (#687) (#748)
|
||||
- Location history abstraction released independently as [`location-history`](https://www.npmjs.com/package/location-history)
|
||||
|
||||
### Fixed
|
||||
- When streaming to VLC, set VLC window title to torrent file name (#746)
|
||||
- Fix "Cannot read property 'numPiecesPresent' of undefined" exception (#695)
|
||||
- Fix rare case where config file could not be completely written (#733)
|
||||
|
||||
## v0.9.0 - 2016-07-20
|
||||
|
||||
### Added
|
||||
- Save selected subtitles
|
||||
- Ask for confirmation before deleting torrents
|
||||
- Support Debian Jessie
|
||||
|
||||
### Changed
|
||||
- Only send telemetry in production
|
||||
- Clean up the code. Split main.js, refactor lots of things
|
||||
|
||||
### Fixed
|
||||
- Fix state.playing.jumpToTime behavior
|
||||
- Remove torrent file and poster image when deleting a torrent
|
||||
|
||||
## v0.8.1 - 2016-06-24
|
||||
|
||||
### Added
|
||||
- New URI handler: stream-magnet
|
||||
|
||||
### Fixed
|
||||
- DLNA crashing bug
|
||||
|
||||
## v0.8.0 - 2016-06-23
|
||||
|
||||
### Added
|
||||
- Cast menu: choose which Chromecast, Airplay, or DLNA device you want to use
|
||||
- Telemetry: send basic data, plus stats on how often the play button works
|
||||
- Make posters from jpeg files, not just jpg
|
||||
- Support .wmv video via Play in VLC
|
||||
- Windows thumbnail bar with a play/pause button
|
||||
|
||||
### Changed
|
||||
- Nicer modal styles
|
||||
|
||||
### Fixed
|
||||
- Windows tray icon now stays in the right state
|
||||
|
||||
## v0.7.2 - 2016-06-02
|
||||
|
||||
### Fixed
|
||||
- Fix exception that affects users upgrading from v0.5.1 or older
|
||||
- Ensure `state.saved.prefs` configuration exists
|
||||
- Fix window title on "About WebTorrent" window
|
||||
|
||||
## v0.7.1 - 2016-06-02
|
||||
|
||||
### Changed
|
||||
- Change "Step Forward" keyboard shortcut to `Alt+Left` (Windows)
|
||||
- Change "Step Backward" keyboard shortcut to to `Alt+Right` (Windows)
|
||||
|
||||
### Fixed
|
||||
- First time startup bug -- invalid torrent/poster paths
|
||||
|
||||
## v0.7.0 - 2016-06-02
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
- Add (+) button now also accepts non .torrent files and creates a torrent from
|
||||
those files
|
||||
- Show prompt text in title bar for open dialogs (OS X)
|
||||
- Upgrade Electron to 1.2.1
|
||||
- Improve window resizing when aspect ratio is enforced (OS X)
|
||||
- Use .ico format for better icon rendering quality (Windows)
|
||||
- Fix crash reporter not working (Windows)
|
||||
|
||||
### Fixed
|
||||
- Re-enable WebRTC (web peers)! (OS X, Windows)
|
||||
- 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
|
||||
- Fix subtitle selector radio button UI size glitch
|
||||
- Fix race condition causing exeption on app startup
|
||||
- Fix duplicate torrent detection in some cases
|
||||
- Fix "gray screen" exception caused by incorrect file list order
|
||||
- Fix torrent loading message UI misalignment
|
||||
|
||||
### Known issues
|
||||
- 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
|
||||
torrent to re-populate the metadata.
|
||||
|
||||
## v0.6.1 - 2016-05-26
|
||||
|
||||
### Fixed
|
||||
- Disable WebRTC to work around Electron crash (Windows)
|
||||
- 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.
|
||||
- Fix crash when updating from WebTorrent 0.5.x in some situtations (#583)
|
||||
- Fix crash when dropping files onto the dock icon (OS X)
|
||||
- Fix keyboard shortcuts Space and ESC being captured globally (#585)
|
||||
- Fix crash, show error when drag-dropping hidden files (#586)
|
||||
|
||||
## v0.6.0 - 2016-05-24
|
||||
|
||||
### Added
|
||||
- Added Preferences page to set Download folder
|
||||
- Save video position, resume playback from saved position
|
||||
- Add additional video player keyboard shortcuts (#275)
|
||||
- Use `poster.jpg` file as the poster image if available (#558)
|
||||
- Associate .torrent files to WebTorrent Desktop (OS X) (#553)
|
||||
- Add support for pasting `leenkx.com/loader` links (#559)
|
||||
- Add announcement feature
|
||||
|
||||
### Changed
|
||||
- Nicer player UI
|
||||
- Reduce startup jank, improve startup time (#568)
|
||||
- Cleanup unsupported codec detection (#569, #570)
|
||||
- Cleaner look for the torrent file list
|
||||
- Improve subtitle positioning (#551)
|
||||
|
||||
### Fixed
|
||||
- Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
|
||||
- Fix bugs in LocationHistory
|
||||
- When player is active, and magnet link is pasted, go back to list
|
||||
- After deleting torrent, remove just the player from forward stack
|
||||
- After creating torrent, remove create torrent page from forward stack
|
||||
- Cancel button on create torrent page should only go back one page
|
||||
|
||||
## v0.5.1 - 2016-05-18
|
||||
|
||||
### Fixed
|
||||
- Fix auto-updater (OS X, Windows).
|
||||
|
||||
## v0.5.0 - 2016-05-17
|
||||
|
||||
### Added
|
||||
- Select/deselect individual files to torrent.
|
||||
- Automatically include subtitle files (.srt, .vtt) from torrent in the subtitles menu.
|
||||
- "Add Subtitle File..." menu item.
|
||||
|
||||
### Changed
|
||||
- When manually adding subtitle track(s), always switch to the new track.
|
||||
|
||||
### Fixed
|
||||
- Magnet links throw exception on app launch. (OS X)
|
||||
- Multi-file torrents would not seed in-place, were copied to Downloads folder.
|
||||
- Missing 'About WebTorrent' menu item. (Windows)
|
||||
- Rare exception. ("Cannot create BrowserWindow before app is ready")
|
||||
|
||||
## v0.4.0 - 2016-05-13
|
||||
|
||||
### Added
|
||||
- Better Windows support!
|
||||
- Windows 32-bit build.
|
||||
- Windows Portable App build.
|
||||
- Windows app signing, for fewer install warnings.
|
||||
- Better Linux support!
|
||||
- Linux 32-bit build.
|
||||
- Subtitles support!
|
||||
- .srt and .vtt file support.
|
||||
- Drag-and-drop files on video, or choose from file selector.
|
||||
- Multiple subtitle files support.
|
||||
- Stream to VLC when the audio codec is unplayable (e.g. AC3, EAC3).
|
||||
- "Show in Folder" item in context menu.
|
||||
- Volume slider, with mute/unmute button.
|
||||
- New "Create torrent" page to modify:
|
||||
- Torrent comment.
|
||||
- Trackers.
|
||||
- Private torrent flag.
|
||||
- Use mouse wheel to increase/decrease volume.
|
||||
- Bounce the Downloads stack when download completes. (OS X)
|
||||
- New default torrent on first launch: The WIRED CD.
|
||||
|
||||
### Changed
|
||||
- Improve app startup time by 40%.
|
||||
- UI tweaks: Reduce font size, reduce torrent list item height.
|
||||
- Add Playback menu for playback-related functionality.
|
||||
- Fix installing when the app is already installed. (Windows)
|
||||
- Don't kill unrelated processes on uninstall. (Windows)
|
||||
- Set "sheet offset" correctly for create torrent dialog. (OS X)
|
||||
- Remove OS X-style Window menu. (Linux, Windows)
|
||||
- Remove "Add Fake Airplay/Chromecast" menu items.
|
||||
|
||||
### Fixed
|
||||
- Disable WebRTC to fix 100% CPU usage/crashes caused by Chromium issue. This is
|
||||
temporary. (OS X)
|
||||
- When fullscreen, make controls use the full window. (OS X)
|
||||
- Support creating torrents that contain .torrent files.
|
||||
- Block power save while casting to a remote device.
|
||||
- Do not block power save when the space key is pressed from the torrent list.
|
||||
- Support playing .mpg and .ogv extensions in the app.
|
||||
- Fix video centering for multi-screen setups.
|
||||
- Show an error when adding a duplicate torrent.
|
||||
- Show an error when adding an invalid magnet link.
|
||||
- Do not stop music when tabbing to another program (OS X)
|
||||
- Properly size the Windows volume mixer icon.
|
||||
- Default to the user's OS-defined, localized "Downloads" folder.
|
||||
- Enforce minimimum window size when resizing player to prevent window disappearing.
|
||||
- Fix rare race condition error on app quit.
|
||||
- Don't use zero-byte torrent "poster" images.
|
||||
|
||||
Thanks to @grunjol, @rguedes, @furstenheim, @karloluis, @DiegoRBaquero, @alxhotel,
|
||||
@AgentEpsilon, @remijouannet, Rolando Guedes, @dcposch, and @feross for contributing
|
||||
to this release!
|
||||
|
||||
## v0.3.3 - 2016-04-07
|
||||
|
||||
### Fixed
|
||||
- App icon was incorrect (OS X)
|
||||
|
||||
## v0.3.2 - 2016-04-07
|
||||
|
||||
### Added
|
||||
- Register WebTorrent as default handler for magnet links (OS X)
|
||||
|
||||
### Changed
|
||||
- Faster startup time (50ms)
|
||||
- Update Electron to 0.37.5
|
||||
- Remove the white flash when loading pages and resizing the window
|
||||
- Fix crash when sending IPC messages
|
||||
|
||||
### Fixed
|
||||
- Fix installation bugs with .deb file (Linux)
|
||||
- Pause audio reliably when closing the window
|
||||
- Enforce minimimum window size when resizing player (for audio-only .mov files, which are 0x0)
|
||||
|
||||
## v0.3.1 - 2016-04-06
|
||||
|
||||
### Added
|
||||
- Add crash reporter to torrent engine process
|
||||
|
||||
### Fixed
|
||||
- Fix cast screen background: cover, don't tile
|
||||
|
||||
## v0.3.0 - 2016-04-06
|
||||
|
||||
### Added
|
||||
- **Ubuntu/Debian support!** (.deb installer)
|
||||
- **DLNA streaming support**
|
||||
- Add "File > Quit" menu item (Linux)
|
||||
- App uninstaller (Windows)
|
||||
- Crash reporting
|
||||
|
||||
### Changed
|
||||
- On startup, do not re-verify files when timestamps are unchanged
|
||||
- Moved torrent engine to an independent process, for better UI performance
|
||||
- Removed media queries (UI resizing based on window width)
|
||||
- Improved Chromecast icon, when connected
|
||||
|
||||
### Fixed
|
||||
- "Download Complete" notification shows consistently
|
||||
- Create new torrents and seed them without copying to temporary folder
|
||||
- Clicking the "Download Complete" notification will always activate app
|
||||
- Fixed harmless "-psn_###" error on first app startup
|
||||
- Hide play buttons on unplayable torrents
|
||||
- Better error handling when Chromecast/Airplay cannot connect
|
||||
- Show player controls immediately on mouse move
|
||||
- When creating a torrent, show it in UI immediately
|
||||
- Stop casting to TV when player is closed
|
||||
- Torrent engine: Fixed memory leaks in `torrent-discovery` and `bittorrent-tracker`
|
||||
- Torrent engine: Fixed sub-optimal tcp/webrtc connection timeouts
|
||||
- Torrent engine: Throttle web seed connections to maximum of 4
|
||||
|
||||
Thanks to @dcposch, @grunjol, and @feross for contributing to this release.
|
||||
|
||||
## v0.2.0 - 2016-03-29
|
||||
|
||||
### Added
|
||||
- Minimise to tray (Windows, Linux)
|
||||
- Show spinner and download speed when player is stalled waiting for data
|
||||
- Highlight window on drag-and-drop
|
||||
- Show notification to update to new app version (Linux)
|
||||
- We have an auto-updater for Windows and Mac. We don't have one for Linux yet, so
|
||||
Linux users need to download new versions manually.
|
||||
|
||||
### Changed
|
||||
- Renamed WebTorrent.app to WebTorrent Desktop
|
||||
- Add Cosmos Laundromat as a default torrent
|
||||
|
||||
### Fixed
|
||||
- Only capture media keys when player is active
|
||||
- Update WebTorrent to 0.88.1 for performance improvements
|
||||
- When seeding, do not proactively connect to new peers
|
||||
- When seeding, do not accept new peers from peer exchange (ut_pex)
|
||||
- Fixed leaks, and other improvements that result in less garbage collection
|
||||
|
||||
Thanks to @dcposch, @ungoldman, and @feross for contributing to this release.
|
||||
|
||||
## v0.1.1 - 2016-03-28
|
||||
|
||||
- Performance improvements
|
||||
- Improve app startup time by over 100%
|
||||
- Reduce the number of DOM updates substantially
|
||||
- Update UI immediately anytime state is changed, instead on 1 second interval
|
||||
- Added right-click menu
|
||||
- Save .torrent File
|
||||
- Copy Leenkx.com Link to Clipboard
|
||||
- Copy Magnet Link to Clipbaord
|
||||
- Added keyboard shortcut for volume up (⌘/Ctrl + ↑) and volume down (⌘/Ctrl + ↓)
|
||||
- Add desktop launcher shortcuts, like OS X has, for KDE and GNOME (Linux)
|
||||
- Add "About" window (Windows, Linux)
|
||||
- Better default window size that fits all the default torrents
|
||||
- Fixed
|
||||
- Crash when ".local/share/{applications,icons}" path did not exist (Linux)
|
||||
- WebTorrent executable can be moved without breaking torrents in the client
|
||||
- Video progress bar shows progress for current file, not full torrent
|
||||
- Video player window shows file title instead of torrent title
|
||||
|
||||
Thanks to @dcposch, @ungoldman, @rom1504, @grunjol, @Flet, and @feross for contributing to
|
||||
this release.
|
||||
|
||||
## v0.1.0 - 2016-03-25
|
||||
|
||||
- **Windows support!**
|
||||
- Includes auto-updater, just like the OS X version.
|
||||
- Installs desktop and start menu shortcuts.
|
||||
- **Audio file support!**
|
||||
- Supports playback of .mp3, .aac, .ogg, .wav
|
||||
- Audio file metadata gets shown in the UI
|
||||
- Top menu is no longer automatically hidden (Windows)
|
||||
- When magnet links are opened from third-party apps, the WebTorrent window now gets focus.
|
||||
- Subtler app sounds.
|
||||
- Fix for an issue that caused some magnet links to fail to open.
|
||||
|
||||
**NOTE:** OS X users must install v0.1.0 manually because the app bundle ID was changed in this release, and the auto-updater cannot handle this condition.
|
||||
|
||||
Thanks to @dcposch, @ungoldman, and @feross for contributing to this release.
|
||||
|
||||
## v0.0.1 - 2016-03-21
|
||||
|
||||
- Wait 10 seconds (instead of 60 seconds) after app launch before checking for updates.
|
||||
|
||||
## v0.0.0 - 2016-03-21
|
||||
|
||||
The first official release of WebTorrent Desktop, the streaming torrent client for OS X,
|
||||
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
|
||||
the coming weeks! If you know JavaScript and want to help us out, there's
|
||||
[lots to do](https://github.com/leenkx/leenkxbox/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+contribution%22)!
|
||||
|
||||
### Features
|
||||
|
||||
- **Lightweight, fast torrent client**
|
||||
- **Beautiful user experience**
|
||||
- **Instantly stream video and audio** from torrents!
|
||||
- WebTorrent fetches file pieces from the network **on-demand**, for instant playback.
|
||||
- Even when the file is not fully downloaded, **seeking still works!** (Seeking just reprioritizes what pieces are fetched from the network.)
|
||||
- Stream videos to **AirPlay** and **Chromecast**
|
||||
- **Pure Javascript**, so it's very easy to contribute code!
|
||||
- Based on the most popular and comprehensive torrent package in Node.js, [`webtorrent`](https://www.npmjs.com/package/webtorrent).
|
||||
- Lots of **features**, without the bloat:
|
||||
- Opens magnet links and .torrent files
|
||||
- Drag-and-drop makes adding torrents easy!
|
||||
- Seed files/folders by dragging them onto the app
|
||||
- Discovers peers via tracker servers, DHT (Distributed Hash Table), and peer exchange
|
||||
- Make the video window "float on top" for watching video while you work!
|
||||
- Supports WebTorrent protocol – for connecting to WebRTC peers (i.e. web browsers)
|
109
RELEASE_PROCESS.md
Normal file
@ -0,0 +1,109 @@
|
||||
## 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.**
|
33
bin/clean.js
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Remove all traces of WebTorrent Desktop from the system (config and temp files).
|
||||
* Useful for developers.
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const rimraf = require('rimraf')
|
||||
|
||||
const config = require('../src/config')
|
||||
const handlers = require('../src/main/handlers')
|
||||
|
||||
// First, remove generated files
|
||||
rimraf.sync('build/')
|
||||
rimraf.sync('dist/')
|
||||
|
||||
// Remove any saved configuration
|
||||
rimraf.sync(config.CONFIG_PATH)
|
||||
|
||||
// Remove any temporary files
|
||||
let tmpPath
|
||||
try {
|
||||
tmpPath = path.join(fs.statSync('/tmp') && '/tmp', 'webtorrent')
|
||||
} catch (err) {
|
||||
tmpPath = path.join(os.tmpdir(), 'webtorrent')
|
||||
}
|
||||
rimraf.sync(tmpPath)
|
||||
|
||||
// Uninstall .torrent file and magnet link handlers
|
||||
handlers.uninstall()
|
12
bin/darwin-entitlements.plist
Normal file
@ -0,0 +1,12 @@
|
||||
<?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>
|
6
bin/open-config.js
Executable file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { CONFIG_PATH } = require('../src/config')
|
||||
const open = require('open')
|
||||
|
||||
open(CONFIG_PATH)
|
627
bin/package.js
Executable file
@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Builds app binaries for Mac, Windows, and Linux.
|
||||
*/
|
||||
|
||||
const cp = require('child_process')
|
||||
const electronPackager = require('electron-packager')
|
||||
const fs = require('fs')
|
||||
const minimist = require('minimist')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const rimraf = require('rimraf')
|
||||
const series = require('run-series')
|
||||
const zip = require('cross-zip')
|
||||
|
||||
const config = require('../src/config')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const BUILD_NAME = config.APP_NAME + '-v' + config.APP_VERSION
|
||||
const BUILD_PATH = path.join(config.ROOT_PATH, 'build')
|
||||
const DIST_PATH = path.join(config.ROOT_PATH, 'dist')
|
||||
const NODE_MODULES_PATH = path.join(config.ROOT_PATH, 'node_modules')
|
||||
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
boolean: [
|
||||
'sign'
|
||||
],
|
||||
default: {
|
||||
package: 'all',
|
||||
sign: false
|
||||
},
|
||||
string: [
|
||||
'package'
|
||||
]
|
||||
})
|
||||
|
||||
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(BUILD_PATH)
|
||||
|
||||
console.log('Build: Transpiling to ES5...')
|
||||
cp.execSync('npm run build', { NODE_ENV: 'production', stdio: 'inherit' })
|
||||
console.log('Build: Transpiled to ES5.')
|
||||
|
||||
const platform = argv._[0]
|
||||
if (platform === 'darwin') {
|
||||
buildDarwin(printDone)
|
||||
} else if (platform === 'win32') {
|
||||
buildWin32(printDone)
|
||||
} else if (platform === 'linux') {
|
||||
buildLinux(printDone)
|
||||
} else {
|
||||
buildDarwin(function (err) {
|
||||
printDone(err)
|
||||
buildWin32(function (err) {
|
||||
printDone(err)
|
||||
buildLinux(printDone)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const all = {
|
||||
// The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata
|
||||
// property on Windows, and `NSHumanReadableCopyright` on Mac.
|
||||
appCopyright: config.APP_COPYRIGHT,
|
||||
|
||||
// The release version of the application. Maps to the `ProductVersion` metadata
|
||||
// property on Windows, and `CFBundleShortVersionString` on Mac.
|
||||
appVersion: pkg.version,
|
||||
|
||||
// Package the application's source code into an archive, using Electron's archive
|
||||
// format. Mitigates issues around long path names on Windows and slightly speeds up
|
||||
// require().
|
||||
asar: {
|
||||
// A glob expression, that unpacks the files with matching names to the
|
||||
// "app.asar.unpacked" directory.
|
||||
unpack: 'WebTorrent*'
|
||||
},
|
||||
|
||||
// The build version of the application. Maps to the FileVersion metadata property on
|
||||
// Windows, and CFBundleVersion on Mac. Note: Windows requires the build version to
|
||||
// start with a number. We're using the version of the underlying WebTorrent library.
|
||||
buildVersion: require('webtorrent/package.json').version,
|
||||
|
||||
// The application source directory.
|
||||
dir: config.ROOT_PATH,
|
||||
|
||||
// Pattern which specifies which files to ignore when copying files to create the
|
||||
// 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)$/,
|
||||
|
||||
// The application name.
|
||||
name: config.APP_NAME,
|
||||
|
||||
// The base directory where the finished package(s) are created.
|
||||
out: DIST_PATH,
|
||||
|
||||
// Replace an already existing output directory.
|
||||
overwrite: true,
|
||||
|
||||
// Runs `npm prune --production` which remove the packages specified in
|
||||
// "devDependencies" before starting to package the app.
|
||||
prune: true,
|
||||
|
||||
// The Electron version that the app is built with (without the leading 'v')
|
||||
electronVersion: require('electron/package.json').version
|
||||
}
|
||||
|
||||
const darwin = {
|
||||
// Build for Mac
|
||||
platform: 'darwin',
|
||||
|
||||
// Build x64 binary only.
|
||||
arch: 'x64',
|
||||
|
||||
// The bundle identifier to use in the application's plist (Mac only).
|
||||
appBundleId: 'io.webtorrent.webtorrent',
|
||||
|
||||
// The application category type, as shown in the Finder via "View" -> "Arrange by
|
||||
// Application Category" when viewing the Applications directory (Mac only).
|
||||
appCategoryType: 'public.app-category.utilities',
|
||||
|
||||
// The bundle identifier to use in the application helper's plist (Mac only).
|
||||
helperBundleId: 'io.webtorrent.webtorrent-helper',
|
||||
|
||||
// Application icon.
|
||||
icon: config.APP_ICON + '.icns'
|
||||
}
|
||||
|
||||
const win32 = {
|
||||
// Build for Windows.
|
||||
platform: 'win32',
|
||||
|
||||
// Build x64 binary only.
|
||||
arch: 'x64',
|
||||
|
||||
// Object hash of application metadata to embed into the executable (Windows only)
|
||||
win32metadata: {
|
||||
|
||||
// Company that produced the file.
|
||||
CompanyName: config.APP_NAME,
|
||||
|
||||
// Name of the program, displayed to users
|
||||
FileDescription: config.APP_NAME,
|
||||
|
||||
// Original name of the file, not including a path. This information enables an
|
||||
// application to determine whether a file has been renamed by a user. The format of
|
||||
// the name depends on the file system for which the file was created.
|
||||
OriginalFilename: config.APP_NAME + '.exe',
|
||||
|
||||
// Name of the product with which the file is distributed.
|
||||
ProductName: config.APP_NAME,
|
||||
|
||||
// Internal name of the file, if one exists, for example, a module name if the file
|
||||
// is a dynamic-link library. If the file has no internal name, this string should be
|
||||
// the original filename, without extension. This string is required.
|
||||
InternalName: config.APP_NAME
|
||||
},
|
||||
|
||||
// Application icon.
|
||||
icon: config.APP_ICON + '.ico'
|
||||
}
|
||||
|
||||
const linux = {
|
||||
// Build for Linux.
|
||||
platform: 'linux',
|
||||
|
||||
// Build x64, armv7l, and arm64 binaries.
|
||||
arch: ['x64', 'armv7l', 'arm64']
|
||||
|
||||
// Note: Application icon for Linux is specified via the BrowserWindow `icon` option.
|
||||
}
|
||||
|
||||
build()
|
||||
|
||||
function buildDarwin (cb) {
|
||||
const plist = require('plist')
|
||||
|
||||
console.log('Mac: Packaging electron...')
|
||||
electronPackager(Object.assign({}, all, darwin)).then(function (buildPath) {
|
||||
console.log('Mac: Packaged electron. ' + buildPath)
|
||||
|
||||
const appPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
const contentsPath = path.join(appPath, 'Contents')
|
||||
const resourcesPath = path.join(contentsPath, 'Resources')
|
||||
const infoPlistPath = path.join(contentsPath, 'Info.plist')
|
||||
const infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
|
||||
|
||||
infoPlist.CFBundleDocumentTypes = [
|
||||
{
|
||||
CFBundleTypeExtensions: ['torrent'],
|
||||
CFBundleTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleTypeName: 'BitTorrent Document',
|
||||
CFBundleTypeRole: 'Editor',
|
||||
LSHandlerRank: 'Owner',
|
||||
LSItemContentTypes: ['org.bittorrent.torrent']
|
||||
},
|
||||
{
|
||||
CFBundleTypeName: 'Any',
|
||||
CFBundleTypeOSTypes: ['****'],
|
||||
CFBundleTypeRole: 'Editor',
|
||||
LSHandlerRank: 'Owner',
|
||||
LSTypeIsPackage: false
|
||||
}
|
||||
]
|
||||
|
||||
infoPlist.CFBundleURLTypes = [
|
||||
{
|
||||
CFBundleTypeRole: 'Editor',
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Magnet URL',
|
||||
CFBundleURLSchemes: ['magnet']
|
||||
},
|
||||
{
|
||||
CFBundleTypeRole: 'Editor',
|
||||
CFBundleURLIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
CFBundleURLName: 'BitTorrent Stream-Magnet URL',
|
||||
CFBundleURLSchemes: ['stream-magnet']
|
||||
}
|
||||
]
|
||||
|
||||
infoPlist.UTExportedTypeDeclarations = [
|
||||
{
|
||||
UTTypeConformsTo: [
|
||||
'public.data',
|
||||
'public.item',
|
||||
'com.bittorrent.torrent'
|
||||
],
|
||||
UTTypeDescription: 'BitTorrent Document',
|
||||
UTTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
|
||||
UTTypeIdentifier: 'org.bittorrent.torrent',
|
||||
UTTypeReferenceURL: 'http://www.bittorrent.org/beps/bep_0000.html',
|
||||
UTTypeTagSpecification: {
|
||||
'com.apple.ostype': 'TORR',
|
||||
'public.filename-extension': ['torrent'],
|
||||
'public.mime-type': 'application/x-bittorrent'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
fs.writeFileSync(infoPlistPath, plist.build(infoPlist))
|
||||
|
||||
// Copy torrent file icon into app bundle
|
||||
cp.execSync(`cp ${config.APP_FILE_ICON + '.icns'} ${resourcesPath}`)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (argv.sign) {
|
||||
signApp(function (err) {
|
||||
if (err) return cb(err)
|
||||
pack(cb)
|
||||
})
|
||||
} else {
|
||||
printWarning()
|
||||
pack(cb)
|
||||
}
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
|
||||
function signApp (cb) {
|
||||
const sign = require('electron-osx-sign')
|
||||
const { notarize } = require('electron-notarize')
|
||||
|
||||
/*
|
||||
* Sign the app with Apple Developer ID certificates. We sign the app for 2 reasons:
|
||||
* - So the auto-updater (Squirrrel.Mac) can check that app updates are signed by
|
||||
* the same author as the current version.
|
||||
* - So users will not a see a warning about the app coming from an "Unidentified
|
||||
* Developer" when they open it for the first time (Mac Gatekeeper).
|
||||
*
|
||||
* To sign an Mac app for distribution outside the App Store, the following are
|
||||
* required:
|
||||
* - Xcode
|
||||
* - Xcode Command Line Tools (xcode-select --install)
|
||||
* - Membership in the Apple Developer Program
|
||||
*/
|
||||
const signOpts = {
|
||||
verbose: true,
|
||||
app: appPath,
|
||||
platform: 'darwin',
|
||||
identity: 'Developer ID Application: Leenkx Team (5MAMC8G3L8)',
|
||||
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...')
|
||||
sign(signOpts, function (err) {
|
||||
if (err) return cb(err)
|
||||
console.log('Mac: Signed app.')
|
||||
|
||||
console.log('Mac: Notarizing app...')
|
||||
notarize(notarizeOpts).then(
|
||||
function () {
|
||||
console.log('Mac: Notarized app.')
|
||||
cb(null)
|
||||
},
|
||||
function (err) {
|
||||
cb(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function pack (cb) {
|
||||
packageZip() // always produce .zip file, used for automatic updates
|
||||
|
||||
if (argv.package === 'dmg' || argv.package === 'all') {
|
||||
packageDmg(cb)
|
||||
}
|
||||
}
|
||||
|
||||
function packageZip () {
|
||||
// Create .zip file (used by the auto-updater)
|
||||
console.log('Mac: Creating zip...')
|
||||
|
||||
const inPath = path.join(buildPath[0], config.APP_NAME + '.app')
|
||||
const outPath = path.join(DIST_PATH, BUILD_NAME + '-darwin.zip')
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log('Mac: Created zip.')
|
||||
}
|
||||
|
||||
function packageDmg (cb) {
|
||||
console.log('Mac: Creating dmg...')
|
||||
|
||||
const appDmg = require('appdmg')
|
||||
|
||||
const targetPath = path.join(DIST_PATH, BUILD_NAME + '.dmg')
|
||||
rimraf.sync(targetPath)
|
||||
|
||||
// Create a .dmg (Mac disk image) file, for easy user installation.
|
||||
const dmgOpts = {
|
||||
basepath: config.ROOT_PATH,
|
||||
target: targetPath,
|
||||
specification: {
|
||||
title: config.APP_NAME,
|
||||
icon: config.APP_ICON + '.icns',
|
||||
background: path.join(config.STATIC_PATH, 'appdmg.png'),
|
||||
'icon-size': 128,
|
||||
contents: [
|
||||
{ x: 122, y: 240, type: 'file', path: appPath },
|
||||
{ x: 380, y: 240, type: 'link', path: '/Applications' },
|
||||
// Hide hidden icons out of view, for users who have hidden files shown.
|
||||
// https://github.com/LinusU/node-appdmg/issues/45#issuecomment-153924954
|
||||
{ x: 50, y: 500, type: 'position', path: '.background' },
|
||||
{ x: 100, y: 500, type: 'position', path: '.DS_Store' },
|
||||
{ x: 150, y: 500, type: 'position', path: '.Trashes' },
|
||||
{ x: 200, y: 500, type: 'position', path: '.VolumeIcon.icns' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const dmg = appDmg(dmgOpts)
|
||||
dmg.once('error', cb)
|
||||
dmg.on('progress', function (info) {
|
||||
if (info.type === 'step-begin') console.log(info.title + '...')
|
||||
})
|
||||
dmg.once('finish', function (info) {
|
||||
console.log('Mac: Created dmg.')
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
}).catch(function (err) {
|
||||
cb(err)
|
||||
})
|
||||
}
|
||||
|
||||
function buildWin32 (cb) {
|
||||
const installer = require('electron-winstaller')
|
||||
console.log('Windows: Packaging electron...')
|
||||
|
||||
/*
|
||||
* Path to folder with the following files:
|
||||
* - Windows Authenticode private key and cert (authenticode.p12)
|
||||
* - Windows Authenticode password file (authenticode.txt)
|
||||
*/
|
||||
let CERT_PATH
|
||||
try {
|
||||
fs.accessSync('D:')
|
||||
CERT_PATH = 'D:'
|
||||
} catch (err) {
|
||||
CERT_PATH = path.join(os.homedir(), 'Desktop')
|
||||
}
|
||||
|
||||
electronPackager(Object.assign({}, all, win32)).then(function (buildPath) {
|
||||
console.log('Windows: Packaged electron. ' + buildPath)
|
||||
|
||||
let signWithParams
|
||||
if (process.platform === 'win32') {
|
||||
if (argv.sign) {
|
||||
const certificateFile = path.join(CERT_PATH, 'authenticode.p12')
|
||||
const certificatePassword = fs.readFileSync(path.join(CERT_PATH, 'authenticode.txt'), 'utf8')
|
||||
const timestampServer = 'http://timestamp.comodoca.com'
|
||||
signWithParams = `/a /f "${certificateFile}" /p "${certificatePassword}" /tr "${timestampServer}" /td sha256`
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
} else {
|
||||
printWarning()
|
||||
}
|
||||
|
||||
const tasks = []
|
||||
buildPath.forEach(function (filesPath) {
|
||||
if (argv.package === 'exe' || argv.package === 'all') {
|
||||
tasks.push((cb) => packageInstaller(filesPath, cb))
|
||||
}
|
||||
if (argv.package === 'portable' || argv.package === 'all') {
|
||||
tasks.push((cb) => packagePortable(filesPath, cb))
|
||||
}
|
||||
})
|
||||
series(tasks, cb)
|
||||
|
||||
function packageInstaller (filesPath, cb) {
|
||||
console.log('Windows: Creating installer...')
|
||||
|
||||
installer.createWindowsInstaller({
|
||||
appDirectory: filesPath,
|
||||
authors: config.APP_TEAM,
|
||||
description: config.APP_NAME,
|
||||
exe: config.APP_NAME + '.exe',
|
||||
iconUrl: config.GITHUB_URL_RAW + '/static/' + config.APP_NAME + '.ico',
|
||||
loadingGif: path.join(config.STATIC_PATH, 'loading.gif'),
|
||||
name: config.APP_NAME,
|
||||
noMsi: true,
|
||||
outputDirectory: DIST_PATH,
|
||||
productName: config.APP_NAME,
|
||||
// TODO: Re-enable Windows 64-bit delta updates when we confirm that they
|
||||
// work correctly in the presence of the "ia32" .nupkg files. I
|
||||
// (feross) noticed them listed in the 64-bit RELEASES file and
|
||||
// manually edited them out for the v0.17 release. Shipping only
|
||||
// full updates for now will work fine, with no ill-effects.
|
||||
// remoteReleases: 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',
|
||||
setupIcon: config.APP_ICON + '.ico',
|
||||
signWithParams,
|
||||
title: config.APP_NAME,
|
||||
usePackageJson: false,
|
||||
version: pkg.version
|
||||
})
|
||||
.then(function () {
|
||||
console.log('Windows: Created installer.')
|
||||
|
||||
/**
|
||||
* Delete extraneous Squirrel files (i.e. *.nupkg delta files for older
|
||||
* versions of the app)
|
||||
*/
|
||||
fs.readdirSync(DIST_PATH)
|
||||
.filter((name) => name.endsWith('.nupkg') && !name.includes(pkg.version))
|
||||
.forEach((filename) => {
|
||||
fs.unlinkSync(path.join(DIST_PATH, filename))
|
||||
})
|
||||
|
||||
cb(null)
|
||||
})
|
||||
.catch(cb)
|
||||
}
|
||||
|
||||
function packagePortable (filesPath, cb) {
|
||||
console.log('Windows: Creating portable app...')
|
||||
|
||||
const portablePath = path.join(filesPath, 'Portable Settings')
|
||||
fs.mkdirSync(portablePath, { recursive: true })
|
||||
|
||||
const downloadsPath = path.join(portablePath, 'Downloads')
|
||||
fs.mkdirSync(downloadsPath, { recursive: true })
|
||||
|
||||
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)
|
||||
|
||||
console.log('Windows: Created portable app.')
|
||||
cb(null)
|
||||
}
|
||||
}).catch(function (err) {
|
||||
cb(err)
|
||||
})
|
||||
}
|
||||
|
||||
function buildLinux (cb) {
|
||||
console.log('Linux: Packaging electron...')
|
||||
|
||||
electronPackager(Object.assign({}, all, linux)).then(function (buildPath) {
|
||||
console.log('Linux: Packaged electron. ' + buildPath)
|
||||
|
||||
const tasks = []
|
||||
buildPath.forEach(function (filesPath) {
|
||||
const destArch = filesPath.split('-').pop()
|
||||
|
||||
if (argv.package === 'deb' || argv.package === 'all') {
|
||||
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') {
|
||||
tasks.push((cb) => packageZip(filesPath, destArch, cb))
|
||||
}
|
||||
})
|
||||
series(tasks, cb)
|
||||
}).catch(function (err) {
|
||||
cb(err)
|
||||
})
|
||||
|
||||
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
|
||||
console.log(`Linux: Creating ${destArch} deb...`)
|
||||
|
||||
const installer = require('electron-installer-debian')
|
||||
|
||||
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'),
|
||||
lintianOverrides: [
|
||||
'unstripped-binary-or-object',
|
||||
'embedded-library',
|
||||
'missing-dependency-on-libc',
|
||||
'changelog-file-missing-in-native-package',
|
||||
'description-synopsis-is-duplicated',
|
||||
'setuid-binary',
|
||||
'binary-without-manpage',
|
||||
'shlib-with-executable-bit'
|
||||
]
|
||||
}
|
||||
|
||||
installer(options).then(
|
||||
() => {
|
||||
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) {
|
||||
// Create .zip file for Linux
|
||||
console.log(`Linux: Creating ${destArch} zip...`)
|
||||
|
||||
const inPath = path.join(DIST_PATH, path.basename(filesPath))
|
||||
const outPath = path.join(DIST_PATH, `${BUILD_NAME}-linux-${destArch}.zip`)
|
||||
zip.zipSync(inPath, outPath)
|
||||
|
||||
console.log(`Linux: Created ${destArch} zip.`)
|
||||
cb(null)
|
||||
}
|
||||
}
|
||||
|
||||
function printDone (err) {
|
||||
if (err) console.error(err.message || err)
|
||||
}
|
||||
|
||||
/*
|
||||
* Print a large warning when signing is disabled so we are less likely to accidentally
|
||||
* ship unsigned binaries to users.
|
||||
*/
|
||||
function printWarning () {
|
||||
console.log(fs.readFileSync(path.join(__dirname, 'warning.txt'), 'utf8'))
|
||||
}
|
25
bin/update-authors.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Update AUTHORS.md based on git history.
|
||||
|
||||
git log --reverse --format='%aN (%aE)' | perl -we '
|
||||
BEGIN {
|
||||
%seen = (), @authors = ();
|
||||
}
|
||||
while (<>) {
|
||||
next if $seen{$_};
|
||||
next if /(support\@greenkeeper.io)/;
|
||||
next if /(ungoldman\@gmail.com)/;
|
||||
next if /(dc\@DCs-MacBook.local)/;
|
||||
next if /(rolandoguedes\@gmail.com)/;
|
||||
next if /(grunjol\@users.noreply.github.com)/;
|
||||
next if /(dependabot)/;
|
||||
$seen{$_} = push @authors, "- ", $_;
|
||||
}
|
||||
END {
|
||||
print "# Authors\n\n";
|
||||
print "#### Ordered by first contribution.\n\n";
|
||||
print @authors, "\n";
|
||||
print "#### Generated by bin/update-authors.sh.\n";
|
||||
}
|
||||
' > AUTHORS.md
|
12
bin/warning.txt
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
*********************************************************
|
||||
_ _ ___ ______ _ _ _____ _ _ _____
|
||||
| | | |/ _ \ | ___ \ \ | |_ _| \ | | __ \
|
||||
| | | / /_\ \| |_/ / \| | | | | \| | | \/
|
||||
| |/\| | _ || /| . ` | | | | . ` | | __
|
||||
\ /\ / | | || |\ \| |\ |_| |_| |\ | |_\ \
|
||||
\/ \/\_| |_/\_| \_\_| \_/\___/\_| \_/\____/
|
||||
|
||||
Application is NOT signed. Do not ship this to users!
|
||||
|
||||
*********************************************************
|
27473
package-lock.json
generated
Normal file
132
package.json
Normal file
@ -0,0 +1,132 @@
|
||||
{
|
||||
"name": "webtorrent-desktop",
|
||||
"description": "WebTorrent, the streaming torrent client. For Mac, Windows, and Linux.",
|
||||
"version": "0.24.0",
|
||||
"author": {
|
||||
"name": "Leenkx Team",
|
||||
"email": "feross@webtorrent.io",
|
||||
"url": "https://webtorrent.io"
|
||||
},
|
||||
"babel": {
|
||||
"plugins": [
|
||||
[
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
{
|
||||
"useBuiltIns": true
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leenkx/leenkxbox/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.1.2",
|
||||
"airplayer": "github:webtorrent/airplayer#fix-security",
|
||||
"application-config": "2.0.0",
|
||||
"arch": "2.2.0",
|
||||
"auto-launch": "5.0.5",
|
||||
"bitfield": "4.1.0",
|
||||
"capture-frame": "4.0.0",
|
||||
"chokidar": "3.5.3",
|
||||
"chromecasts": "1.10.2",
|
||||
"create-torrent": "5.0.9",
|
||||
"debounce": "1.2.1",
|
||||
"dlnacasts": "0.1.0",
|
||||
"drag-drop": "7.2.0",
|
||||
"es6-error": "4.1.1",
|
||||
"fn-getter": "1.0.0",
|
||||
"iso-639-1": "2.1.15",
|
||||
"languagedetect": "2.0.0",
|
||||
"location-history": "1.1.2",
|
||||
"material-ui": "0.20.2",
|
||||
"music-metadata": "7.14.0",
|
||||
"network-address": "1.1.2",
|
||||
"parse-torrent": "9.1.5",
|
||||
"prettier-bytes": "1.0.4",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"rimraf": "4.4.0",
|
||||
"run-parallel": "1.2.0",
|
||||
"semver": "7.3.8",
|
||||
"simple-concat": "1.0.1",
|
||||
"simple-get": "4.0.1",
|
||||
"srt-to-vtt": "1.1.3",
|
||||
"vlc-command": "1.2.0",
|
||||
"webtorrent": "1.9.7",
|
||||
"winreg": "1.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.26.4",
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/eslint-parser": "7.26.5",
|
||||
"@babel/plugin-transform-react-jsx": "7.25.9",
|
||||
"cross-zip": "4.0.0",
|
||||
"depcheck": "1.4.7",
|
||||
"electron": "27.3.11",
|
||||
"electron-notarize": "1.2.2",
|
||||
"electron-osx-sign": "0.6.0",
|
||||
"electron-packager": "17.1.2",
|
||||
"electron-winstaller": "5.4.0",
|
||||
"gh-release": "7.0.2",
|
||||
"minimist": "1.2.8",
|
||||
"nodemon": "2.0.22",
|
||||
"open": "8.4.2",
|
||||
"plist": "3.1.0",
|
||||
"pngjs": "7.0.0",
|
||||
"run-series": "1.1.9",
|
||||
"spectron": "19.0.0",
|
||||
"standard": "17.0.0",
|
||||
"tape": "5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || ^18.0.0",
|
||||
"npm": "^7.10.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
},
|
||||
"homepage": "https://webtorrent.io",
|
||||
"keywords": [
|
||||
"desktop",
|
||||
"electron",
|
||||
"electron-app",
|
||||
"hybrid webtorrent client",
|
||||
"mad science",
|
||||
"torrent",
|
||||
"torrent client",
|
||||
"webtorrent"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"optionalDependencies": {
|
||||
"appdmg": "^0.6.0",
|
||||
"electron-installer-debian": "^3.2.0",
|
||||
"electron-installer-redhat": "^3.4.0"
|
||||
},
|
||||
"private": true,
|
||||
"productName": "WebTorrent",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/leenkx/leenkxbox.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src --out-dir build",
|
||||
"clean": "node ./bin/clean.js",
|
||||
"gh-release": "gh-release",
|
||||
"install-system-deps": "brew install fakeroot dpkg rpm",
|
||||
"open-config": "node ./bin/open-config.js",
|
||||
"package": "node ./bin/package.js",
|
||||
"start": "npm run build && electron --no-sandbox .",
|
||||
"test": "standard && depcheck --ignores=standard,@babel/eslint-parser --ignore-dirs=build,dist",
|
||||
"test-integration": "npm run build && node ./test",
|
||||
"update-authors": "./bin/update-authors.sh",
|
||||
"watch": "nodemon --exec \"npm run start\" --ext js,css --ignore build/ --ignore dist/"
|
||||
},
|
||||
"standard": {
|
||||
"parser": "@babel/eslint-parser"
|
||||
},
|
||||
"renovate": {
|
||||
"extends": [
|
||||
"github>webtorrent/renovate-config"
|
||||
]
|
||||
}
|
||||
}
|
176
src/config.js
Normal file
@ -0,0 +1,176 @@
|
||||
const appConfig = require('application-config')('WebTorrent')
|
||||
const path = require('path')
|
||||
const { app } = require('electron')
|
||||
const arch = require('arch')
|
||||
|
||||
const APP_NAME = 'LeenkxBox'
|
||||
const APP_TEAM = 'Leenkx Team'
|
||||
const APP_VERSION = require('../package.json').version
|
||||
|
||||
const IS_TEST = isTest()
|
||||
const PORTABLE_PATH = IS_TEST
|
||||
? path.join(process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp', 'WebTorrentTest')
|
||||
: path.join(path.dirname(process.execPath), 'Portable Settings')
|
||||
const IS_PRODUCTION = isProduction()
|
||||
const IS_PORTABLE = isPortable()
|
||||
|
||||
const UI_HEADER_HEIGHT = 38
|
||||
const UI_TORRENT_HEIGHT = 100
|
||||
|
||||
module.exports = {
|
||||
ANNOUNCEMENT_URL: 'https://leenkx.com/leenkxbox/announcement',
|
||||
AUTO_UPDATE_URL: 'https://leenkx.com/leenkxbox/update',
|
||||
CRASH_REPORT_URL: 'https://leenkx.com/leenkxbox/crash-report',
|
||||
TELEMETRY_URL: 'https://leenkx.com/leenkxbox/telemetry',
|
||||
|
||||
APP_COPYRIGHT: `Copyright © 2014-${new Date().getFullYear()} ${APP_TEAM}`,
|
||||
APP_FILE_ICON: path.join(__dirname, '..', 'static', 'WebTorrentFile'),
|
||||
APP_ICON: path.join(__dirname, '..', 'static', 'WebTorrent'),
|
||||
APP_NAME,
|
||||
APP_TEAM,
|
||||
APP_VERSION,
|
||||
APP_WINDOW_TITLE: APP_NAME,
|
||||
|
||||
CONFIG_PATH: getConfigPath(),
|
||||
|
||||
DEFAULT_TORRENTS: [
|
||||
{
|
||||
testID: 'bbb',
|
||||
name: 'Big Buck Bunny',
|
||||
posterFileName: 'bigBuckBunny.jpg',
|
||||
torrentFileName: 'bigBuckBunny.torrent'
|
||||
},
|
||||
{
|
||||
testID: 'cosmos',
|
||||
name: 'Cosmos Laundromat (Preview)',
|
||||
posterFileName: 'cosmosLaundromat.jpg',
|
||||
torrentFileName: 'cosmosLaundromat.torrent'
|
||||
},
|
||||
{
|
||||
testID: 'sintel',
|
||||
name: 'Sintel',
|
||||
posterFileName: 'sintel.jpg',
|
||||
torrentFileName: 'sintel.torrent'
|
||||
},
|
||||
{
|
||||
testID: 'tears',
|
||||
name: 'Tears of Steel',
|
||||
posterFileName: 'tearsOfSteel.jpg',
|
||||
torrentFileName: 'tearsOfSteel.torrent'
|
||||
},
|
||||
{
|
||||
testID: 'wired',
|
||||
name: 'The WIRED CD - Rip. Sample. Mash. Share',
|
||||
posterFileName: 'wiredCd.jpg',
|
||||
torrentFileName: 'wiredCd.torrent'
|
||||
}
|
||||
],
|
||||
|
||||
DELAYED_INIT: 3000 /* 3 seconds */,
|
||||
|
||||
DEFAULT_DOWNLOAD_PATH: getDefaultDownloadPath(),
|
||||
|
||||
GITHUB_URL: 'https://github.com/leenkx/leenkxbox',
|
||||
GITHUB_URL_ISSUES: 'https://github.com/leenkx/leenkxbox/issues',
|
||||
GITHUB_URL_RAW: 'https://raw.githubusercontent.com/webtorrent/webtorrent-desktop/master',
|
||||
GITHUB_URL_RELEASES: 'https://github.com/leenkx/leenkxbox/releases',
|
||||
|
||||
HOME_PAGE_URL: 'https://leenkx.com',
|
||||
TWITTER_PAGE_URL: 'https://twitter.com/Leenkx',
|
||||
|
||||
IS_PORTABLE,
|
||||
IS_PRODUCTION,
|
||||
IS_TEST,
|
||||
|
||||
OS_SYSARCH: arch() === 'x64' ? 'x64' : 'ia32',
|
||||
|
||||
POSTER_PATH: path.join(getConfigPath(), 'Posters'),
|
||||
ROOT_PATH: path.join(__dirname, '..'),
|
||||
STATIC_PATH: path.join(__dirname, '..', 'static'),
|
||||
TORRENT_PATH: path.join(getConfigPath(), 'Torrents'),
|
||||
|
||||
WINDOW_ABOUT: 'file://' + path.join(__dirname, '..', 'static', 'about.html'),
|
||||
WINDOW_MAIN: 'file://' + path.join(__dirname, '..', 'static', 'main.html'),
|
||||
WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, '..', 'static', 'webtorrent.html'),
|
||||
|
||||
WINDOW_INITIAL_BOUNDS: {
|
||||
width: 500,
|
||||
height: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 6) // header + 6 torrents
|
||||
},
|
||||
WINDOW_MIN_HEIGHT: UI_HEADER_HEIGHT + (UI_TORRENT_HEIGHT * 2), // header + 2 torrents
|
||||
WINDOW_MIN_WIDTH: 425,
|
||||
|
||||
UI_HEADER_HEIGHT,
|
||||
UI_TORRENT_HEIGHT
|
||||
}
|
||||
|
||||
function getConfigPath () {
|
||||
if (IS_PORTABLE) {
|
||||
return PORTABLE_PATH
|
||||
} else {
|
||||
return path.dirname(appConfig.filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultDownloadPath () {
|
||||
if (IS_PORTABLE) {
|
||||
return path.join(getConfigPath(), 'Downloads')
|
||||
} else {
|
||||
return getPath('downloads')
|
||||
}
|
||||
}
|
||||
|
||||
function getPath (key) {
|
||||
if (!process.versions.electron) {
|
||||
// Node.js process
|
||||
return ''
|
||||
} else if (process.type === 'renderer') {
|
||||
// Electron renderer process
|
||||
return require('@electron/remote').app.getPath(key)
|
||||
} else {
|
||||
// Electron main process
|
||||
return app.getPath(key)
|
||||
}
|
||||
}
|
||||
|
||||
function isTest () {
|
||||
return process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
function isPortable () {
|
||||
if (IS_TEST) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32' || !IS_PRODUCTION) {
|
||||
// Fast path: Non-Windows platforms should not check for path on disk
|
||||
return false
|
||||
}
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
try {
|
||||
// This line throws if the "Portable Settings" folder does not exist, and does
|
||||
// nothing otherwise.
|
||||
fs.accessSync(PORTABLE_PATH, fs.constants.R_OK | fs.constants.W_OK)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isProduction () {
|
||||
if (!process.versions.electron) {
|
||||
// Node.js process
|
||||
return false
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return !/\/Electron\.app\//.test(process.execPath)
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return !/\\electron\.exe$/.test(process.execPath)
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return !/\/electron$/.test(process.execPath)
|
||||
}
|
||||
}
|
15
src/crash-reporter.js
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
function init () {
|
||||
const config = require('./config')
|
||||
const { crashReporter } = require('electron')
|
||||
|
||||
crashReporter.start({
|
||||
productName: config.APP_NAME,
|
||||
submitURL: config.CRASH_REPORT_URL,
|
||||
globalExtra: { _companyName: config.APP_NAME },
|
||||
compress: true
|
||||
})
|
||||
}
|
54
src/main/announcement.js
Normal file
@ -0,0 +1,54 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
const { dialog } = require('electron')
|
||||
|
||||
const config = require('../config')
|
||||
const log = require('./log')
|
||||
|
||||
const ANNOUNCEMENT_URL =
|
||||
`${config.ANNOUNCEMENT_URL}?version=${config.APP_VERSION}&platform=${process.platform}`
|
||||
|
||||
/**
|
||||
* In certain situations, the WebTorrent team may need to show an announcement to
|
||||
* all WebTorrent Desktop users. For example: a security notice, or an update
|
||||
* notification (if the auto-updater stops working).
|
||||
*
|
||||
* When there is an announcement, the `ANNOUNCEMENT_URL` endpoint should return an
|
||||
* HTTP 200 status code with a JSON object like this:
|
||||
*
|
||||
* {
|
||||
* "title": "WebTorrent Desktop Announcement",
|
||||
* "message": "Security Issue in v0.xx",
|
||||
* "detail": "Please update to v0.xx as soon as possible..."
|
||||
* }
|
||||
*/
|
||||
function init () {
|
||||
const get = require('simple-get')
|
||||
get.concat(ANNOUNCEMENT_URL, onResponse)
|
||||
}
|
||||
|
||||
function onResponse (err, res, data) {
|
||||
if (err) return log(`Failed to retrieve announcement: ${err.message}`)
|
||||
if (res.statusCode !== 200) return log('No announcement available')
|
||||
|
||||
try {
|
||||
data = JSON.parse(data.toString())
|
||||
} catch (err) {
|
||||
// Support plaintext announcement messages, using a default title.
|
||||
data = {
|
||||
title: 'WebTorrent Desktop Announcement',
|
||||
message: data.toString(),
|
||||
detail: data.toString()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: ['OK'],
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
detail: data.detail
|
||||
})
|
||||
}
|
121
src/main/dialog.js
Normal file
@ -0,0 +1,121 @@
|
||||
module.exports = {
|
||||
openSeedFile,
|
||||
openSeedDirectory,
|
||||
openTorrentFile,
|
||||
openTorrentAddress,
|
||||
openFiles
|
||||
}
|
||||
|
||||
const { dialog } = require('electron')
|
||||
|
||||
const log = require('./log')
|
||||
const windows = require('./windows')
|
||||
|
||||
/**
|
||||
* Show open dialog to create a single-file torrent.
|
||||
*/
|
||||
function openSeedFile () {
|
||||
if (!windows.main.win) return
|
||||
log('openSeedFile')
|
||||
const opts = {
|
||||
title: 'Select a file for the torrent.',
|
||||
properties: ['openFile']
|
||||
}
|
||||
showOpenSeed(opts)
|
||||
}
|
||||
|
||||
/*
|
||||
* Show open dialog to create a single-file or single-directory torrent. On
|
||||
* Windows and Linux, open dialogs are for files *or* directories only, not both,
|
||||
* so this function shows a directory dialog on those platforms.
|
||||
*/
|
||||
function openSeedDirectory () {
|
||||
if (!windows.main.win) return
|
||||
log('openSeedDirectory')
|
||||
const opts = process.platform === 'darwin'
|
||||
? {
|
||||
title: 'Select a file or folder for the torrent.',
|
||||
properties: ['openFile', 'openDirectory']
|
||||
}
|
||||
: {
|
||||
title: 'Select a folder for the torrent.',
|
||||
properties: ['openDirectory']
|
||||
}
|
||||
showOpenSeed(opts)
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
function openFiles () {
|
||||
if (!windows.main.win) return
|
||||
log('openFiles')
|
||||
const opts = process.platform === 'darwin'
|
||||
? {
|
||||
title: 'Select a file or folder to add.',
|
||||
properties: ['openFile', 'openDirectory']
|
||||
}
|
||||
: {
|
||||
title: 'Select a file to add.',
|
||||
properties: ['openFile']
|
||||
}
|
||||
setTitle(opts.title)
|
||||
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts)
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
windows.main.dispatch('onOpen', selectedPaths)
|
||||
}
|
||||
|
||||
/*
|
||||
* Show open dialog to open a .torrent file.
|
||||
*/
|
||||
function openTorrentFile () {
|
||||
if (!windows.main.win) return
|
||||
log('openTorrentFile')
|
||||
const opts = {
|
||||
title: 'Select a .torrent file.',
|
||||
filters: [{ name: 'Torrent Files', extensions: ['torrent'] }],
|
||||
properties: ['openFile', 'multiSelections']
|
||||
}
|
||||
setTitle(opts.title)
|
||||
const selectedPaths = dialog.showOpenDialogSync(windows.main.win, opts)
|
||||
resetTitle()
|
||||
if (!Array.isArray(selectedPaths)) return
|
||||
selectedPaths.forEach(selectedPath => {
|
||||
windows.main.dispatch('addTorrent', selectedPath)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Show modal dialog to open a torrent URL (magnet uri, http torrent link, etc.)
|
||||
*/
|
||||
function openTorrentAddress () {
|
||||
log('openTorrentAddress')
|
||||
windows.main.dispatch('openTorrentAddress')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialogs on do not show a title on Mac, so the window title is used instead.
|
||||
*/
|
||||
function setTitle (title) {
|
||||
if (process.platform === 'darwin') {
|
||||
windows.main.dispatch('setTitle', title)
|
||||
}
|
||||
}
|
||||
|
||||
function 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)
|
||||
}
|
59
src/main/dock.js
Normal file
@ -0,0 +1,59 @@
|
||||
module.exports = {
|
||||
downloadFinished,
|
||||
init,
|
||||
setBadge
|
||||
}
|
||||
|
||||
const { app, Menu } = require('electron')
|
||||
|
||||
const dialog = require('./dialog')
|
||||
const log = require('./log')
|
||||
|
||||
/**
|
||||
* Add a right-click menu to the dock icon. (Mac)
|
||||
*/
|
||||
function init () {
|
||||
if (!app.dock) return
|
||||
const menu = Menu.buildFromTemplate(getMenuTemplate())
|
||||
app.dock.setMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce the Downloads stack if `path` is inside the Downloads folder. (Mac)
|
||||
*/
|
||||
function downloadFinished (path) {
|
||||
if (!app.dock) return
|
||||
log(`downloadFinished: ${path}`)
|
||||
app.dock.downloadFinished(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a counter badge for the app. (Mac, Linux)
|
||||
*/
|
||||
function setBadge (count) {
|
||||
if (process.platform === 'darwin' ||
|
||||
(process.platform === 'linux' && app.isUnityRunning())) {
|
||||
log(`setBadge: ${count}`)
|
||||
app.badgeCount = Number(count)
|
||||
}
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
return [
|
||||
{
|
||||
label: 'Create New Torrent...',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => dialog.openSeedDirectory()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent File...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => dialog.openTorrentFile()
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent Address...',
|
||||
accelerator: 'CmdOrCtrl+U',
|
||||
click: () => dialog.openTorrentAddress()
|
||||
}
|
||||
]
|
||||
}
|
76
src/main/external-player.js
Normal file
@ -0,0 +1,76 @@
|
||||
module.exports = {
|
||||
spawn,
|
||||
kill,
|
||||
checkInstall
|
||||
}
|
||||
|
||||
const cp = require('child_process')
|
||||
const path = require('path')
|
||||
const vlcCommand = require('vlc-command')
|
||||
|
||||
const log = require('./log')
|
||||
const windows = require('./windows')
|
||||
|
||||
// holds a ChildProcess while we're playing a video in an external player, null otherwise
|
||||
let proc = null
|
||||
|
||||
function checkInstall (playerPath, cb) {
|
||||
// check for VLC if external player has not been specified by the user
|
||||
// otherwise assume the player is installed
|
||||
if (!playerPath) return vlcCommand(cb)
|
||||
process.nextTick(() => cb(null))
|
||||
}
|
||||
|
||||
function spawn (playerPath, url, title) {
|
||||
if (playerPath) return spawnExternal(playerPath, [url])
|
||||
|
||||
// Try to find and use VLC if external player is not specified
|
||||
vlcCommand((err, vlcPath) => {
|
||||
if (err) return windows.main.dispatch('externalPlayerNotFound')
|
||||
const args = [
|
||||
'--play-and-exit',
|
||||
'--quiet',
|
||||
`--meta-title=${JSON.stringify(title)}`,
|
||||
url
|
||||
]
|
||||
spawnExternal(vlcPath, args)
|
||||
})
|
||||
}
|
||||
|
||||
function kill () {
|
||||
if (!proc) return
|
||||
log(`Killing external player, pid ${proc.pid}`)
|
||||
proc.kill('SIGKILL') // kill -9
|
||||
proc = null
|
||||
}
|
||||
|
||||
function spawnExternal (playerPath, args) {
|
||||
log('Running external media player:', `${playerPath} ${args.join(' ')}`)
|
||||
|
||||
if (process.platform === 'darwin' && path.extname(playerPath) === '.app') {
|
||||
// Mac: Use executable in packaged .app bundle
|
||||
playerPath += `/Contents/MacOS/${path.basename(playerPath, '.app')}`
|
||||
}
|
||||
|
||||
proc = cp.spawn(playerPath, args, { stdio: 'ignore' })
|
||||
|
||||
// If it works, close the modal after a second
|
||||
const closeModalTimeout = setTimeout(() =>
|
||||
windows.main.dispatch('exitModal'), 1000)
|
||||
|
||||
proc.on('close', code => {
|
||||
clearTimeout(closeModalTimeout)
|
||||
if (!proc) return // Killed
|
||||
log('External player exited with code ', code)
|
||||
if (code === 0) {
|
||||
windows.main.dispatch('backToList')
|
||||
} else {
|
||||
windows.main.dispatch('externalPlayerNotFound')
|
||||
}
|
||||
proc = null
|
||||
})
|
||||
|
||||
proc.on('error', err => {
|
||||
log('External player error', err)
|
||||
})
|
||||
}
|
50
src/main/folder-watcher.js
Normal file
@ -0,0 +1,50 @@
|
||||
const chokidar = require('chokidar')
|
||||
const log = require('./log')
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FolderWatcher
|
266
src/main/handlers.js
Normal file
@ -0,0 +1,266 @@
|
||||
module.exports = {
|
||||
install,
|
||||
uninstall
|
||||
}
|
||||
|
||||
const config = require('../config')
|
||||
const path = require('path')
|
||||
|
||||
function install () {
|
||||
switch (process.platform) {
|
||||
case 'darwin': installDarwin()
|
||||
break
|
||||
case 'win32': installWin32()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function uninstall () {
|
||||
switch (process.platform) {
|
||||
case 'darwin': uninstallDarwin()
|
||||
break
|
||||
case 'win32': uninstallWin32()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function installDarwin () {
|
||||
const { app } = require('electron')
|
||||
|
||||
// On Mac, only protocols that are listed in `Info.plist` can be set as the
|
||||
// default handler at runtime.
|
||||
app.setAsDefaultProtocolClient('magnet')
|
||||
app.setAsDefaultProtocolClient('stream-magnet')
|
||||
|
||||
// File handlers are defined in `Info.plist`.
|
||||
}
|
||||
|
||||
function uninstallDarwin () {}
|
||||
|
||||
const EXEC_COMMAND = [process.execPath, '--']
|
||||
|
||||
if (!config.IS_PRODUCTION) {
|
||||
EXEC_COMMAND.push(config.ROOT_PATH)
|
||||
}
|
||||
|
||||
function installWin32 () {
|
||||
const Registry = require('winreg')
|
||||
|
||||
const log = require('./log')
|
||||
|
||||
const iconPath = path.join(
|
||||
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:
|
||||
*
|
||||
* HKEY_CLASSES_ROOT
|
||||
* $PROTOCOL
|
||||
* (Default) = "$NAME"
|
||||
* URL Protocol = ""
|
||||
* DefaultIcon
|
||||
* (Default) = "$ICON"
|
||||
* shell
|
||||
* open
|
||||
* command
|
||||
* (Default) = "$COMMAND" "%1"
|
||||
*
|
||||
* Source: https://msdn.microsoft.com/en-us/library/aa767914.aspx
|
||||
*
|
||||
* However, the "HKEY_CLASSES_ROOT" key can only be written by the Administrator user.
|
||||
* So, we instead write to "HKEY_CURRENT_USER\Software\Classes", which is inherited by
|
||||
* "HKEY_CLASSES_ROOT" anyway, and can be written by unprivileged users.
|
||||
*/
|
||||
|
||||
function registerProtocolHandlerWin32 (protocol, name, icon, command) {
|
||||
const protocolKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: '\\Software\\Classes\\' + protocol
|
||||
})
|
||||
|
||||
setProtocol()
|
||||
|
||||
function setProtocol (err) {
|
||||
if (err) return log.error(err.message)
|
||||
protocolKey.set('', Registry.REG_SZ, name, setURLProtocol)
|
||||
}
|
||||
|
||||
function setURLProtocol (err) {
|
||||
if (err) return log.error(err.message)
|
||||
protocolKey.set('URL Protocol', Registry.REG_SZ, '', setIcon)
|
||||
}
|
||||
|
||||
function setIcon (err) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
const iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${protocol}\\DefaultIcon`
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, setCommand)
|
||||
}
|
||||
|
||||
function setCommand (err) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
const commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${protocol}\\shell\\open\\command`
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
|
||||
}
|
||||
|
||||
function done (err) {
|
||||
if (err) return log.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To add a file handler, the following keys must be added to the Windows registry:
|
||||
*
|
||||
* HKEY_CLASSES_ROOT
|
||||
* $EXTENSION
|
||||
* (Default) = "$EXTENSION_ID"
|
||||
* $EXTENSION_ID
|
||||
* (Default) = "$NAME"
|
||||
* DefaultIcon
|
||||
* (Default) = "$ICON"
|
||||
* shell
|
||||
* open
|
||||
* command
|
||||
* (Default) = "$COMMAND" "%1"
|
||||
*/
|
||||
function registerFileHandlerWin32 (ext, id, name, icon, command) {
|
||||
setExt()
|
||||
|
||||
function setExt () {
|
||||
const extKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: `\\Software\\Classes\\${ext}`
|
||||
})
|
||||
extKey.set('', Registry.REG_SZ, id, setId)
|
||||
}
|
||||
|
||||
function setId (err) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
const idKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${id}`
|
||||
})
|
||||
idKey.set('', Registry.REG_SZ, name, setIcon)
|
||||
}
|
||||
|
||||
function setIcon (err) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
const iconKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${id}\\DefaultIcon`
|
||||
})
|
||||
iconKey.set('', Registry.REG_SZ, icon, setCommand)
|
||||
}
|
||||
|
||||
function setCommand (err) {
|
||||
if (err) return log.error(err.message)
|
||||
|
||||
const commandKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${id}\\shell\\open\\command`
|
||||
})
|
||||
commandKey.set('', Registry.REG_SZ, `${commandToArgs(command)} "%1"`, done)
|
||||
}
|
||||
|
||||
function done (err) {
|
||||
if (err) return log.error(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallWin32 () {
|
||||
const Registry = require('winreg')
|
||||
|
||||
unregisterProtocolHandlerWin32('magnet', EXEC_COMMAND)
|
||||
unregisterProtocolHandlerWin32('stream-magnet', EXEC_COMMAND)
|
||||
unregisterFileHandlerWin32('.torrent', 'io.webtorrent.torrent', EXEC_COMMAND)
|
||||
|
||||
function unregisterProtocolHandlerWin32 (protocol, command) {
|
||||
getCommand()
|
||||
|
||||
function getCommand () {
|
||||
const commandKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: `\\Software\\Classes\\${protocol}\\shell\\open\\command`
|
||||
})
|
||||
commandKey.get('', (err, item) => {
|
||||
if (!err && item.value.indexOf(commandToArgs(command)) >= 0) {
|
||||
destroyProtocol()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function destroyProtocol () {
|
||||
const protocolKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${protocol}`
|
||||
})
|
||||
protocolKey.destroy(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterFileHandlerWin32 (ext, id, command) {
|
||||
eraseId()
|
||||
|
||||
function eraseId () {
|
||||
const idKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: `\\Software\\Classes\\${id}`
|
||||
})
|
||||
idKey.destroy(getExt)
|
||||
}
|
||||
|
||||
function getExt () {
|
||||
const extKey = new Registry({
|
||||
hive: Registry.HKCU,
|
||||
key: `\\Software\\Classes\\${ext}`
|
||||
})
|
||||
extKey.get('', (err, item) => {
|
||||
if (!err && item.value === id) {
|
||||
destroyExt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function destroyExt () {
|
||||
const extKey = new Registry({
|
||||
hive: Registry.HKCU, // HKEY_CURRENT_USER
|
||||
key: `\\Software\\Classes\\${ext}`
|
||||
})
|
||||
extKey.destroy(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commandToArgs (command) {
|
||||
return command.map((arg) => `"${arg}"`).join(' ')
|
||||
}
|
240
src/main/index.js
Normal file
@ -0,0 +1,240 @@
|
||||
console.time('init')
|
||||
|
||||
require('@electron/remote/main').initialize()
|
||||
const { app, ipcMain } = require('electron')
|
||||
|
||||
// Start crash reporter early, so it takes effect for child processes
|
||||
const crashReporter = require('../crash-reporter')
|
||||
crashReporter.init()
|
||||
|
||||
const parallel = require('run-parallel')
|
||||
|
||||
const config = require('../config')
|
||||
const ipc = require('./ipc')
|
||||
const log = require('./log')
|
||||
const menu = require('./menu')
|
||||
const State = require('../renderer/lib/state')
|
||||
const windows = require('./windows')
|
||||
|
||||
const WEBTORRENT_VERSION = require('webtorrent/package.json').version
|
||||
|
||||
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) {
|
||||
// When Electron is running in production mode (packaged app), then run React
|
||||
// in production mode too.
|
||||
process.env.NODE_ENV = 'production'
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const squirrelWin32 = require('./squirrel-win32')
|
||||
shouldQuit = squirrelWin32.handleEvent(argv[0])
|
||||
argv = argv.filter((arg) => !arg.includes('--squirrel'))
|
||||
}
|
||||
|
||||
if (!shouldQuit && !config.IS_PORTABLE) {
|
||||
// Prevent multiple instances of app from running at same time. New instances
|
||||
// signal this instance and quit. Note: This feature creates a lock file in
|
||||
// %APPDATA%\Roaming\WebTorrent so we do not do it for the Portable App since
|
||||
// we want to be "silent" as well as "portable".
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
shouldQuit = true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldQuit) {
|
||||
app.quit()
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
|
||||
function init () {
|
||||
app.on('second-instance', (event, commandLine, workingDirectory) => onAppOpen(commandLine))
|
||||
if (config.IS_PORTABLE) {
|
||||
const path = require('path')
|
||||
// Put all user data into the "Portable Settings" folder
|
||||
app.setPath('userData', config.CONFIG_PATH)
|
||||
// Put Electron crash files, etc. into the "Portable Settings\Temp" folder
|
||||
app.setPath('temp', path.join(config.CONFIG_PATH, 'Temp'))
|
||||
}
|
||||
|
||||
let isReady = false // app ready, windows can be created
|
||||
app.ipcReady = false // main window has finished loading and IPC is ready
|
||||
app.isQuitting = false
|
||||
|
||||
parallel({
|
||||
appReady: (cb) => app.on('ready', () => cb(null)),
|
||||
state: (cb) => State.load(cb)
|
||||
}, onReady)
|
||||
|
||||
function onReady (err, results) {
|
||||
if (err) throw err
|
||||
|
||||
isReady = true
|
||||
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-url', onOpen)
|
||||
|
||||
ipc.init()
|
||||
|
||||
app.once('ipcReady', () => {
|
||||
log('Command line args:', argv)
|
||||
processArgv(argv)
|
||||
console.timeEnd('init')
|
||||
})
|
||||
|
||||
app.on('before-quit', e => {
|
||||
if (app.isQuitting) return
|
||||
|
||||
app.isQuitting = true
|
||||
e.preventDefault()
|
||||
windows.main.dispatch('stateSaveImmediate') // try to save state on exit
|
||||
ipcMain.once('stateSaved', () => app.quit())
|
||||
setTimeout(() => {
|
||||
console.error('Saving state took too long. Quitting.')
|
||||
app.quit()
|
||||
}, 4000) // quit after 4 secs, at most
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (isReady) windows.main.show()
|
||||
})
|
||||
}
|
||||
|
||||
function delayedInit (state) {
|
||||
if (app.isQuitting) return
|
||||
|
||||
const announcement = require('./announcement')
|
||||
const dock = require('./dock')
|
||||
const updater = require('./updater')
|
||||
const FolderWatcher = require('./folder-watcher')
|
||||
const folderWatcher = new FolderWatcher({ window: windows.main, state })
|
||||
|
||||
announcement.init()
|
||||
dock.init()
|
||||
updater.init()
|
||||
|
||||
ipc.setModule('folderWatcher', folderWatcher)
|
||||
if (folderWatcher.isEnabled()) {
|
||||
folderWatcher.start()
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const userTasks = require('./user-tasks')
|
||||
userTasks.init()
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
const tray = require('./tray')
|
||||
tray.init()
|
||||
}
|
||||
}
|
||||
|
||||
function onOpen (e, torrentId) {
|
||||
e.preventDefault()
|
||||
|
||||
if (app.ipcReady) {
|
||||
// Magnet links opened from Chrome won't focus the app without a setTimeout.
|
||||
// The confirmation dialog Chrome shows causes Chrome to steal back the focus.
|
||||
// Electron issue: https://github.com/atom/electron/issues/4338
|
||||
setTimeout(() => windows.main.show(), 100)
|
||||
|
||||
processArgv([torrentId])
|
||||
} else {
|
||||
argv.push(torrentId)
|
||||
}
|
||||
}
|
||||
|
||||
function onAppOpen (newArgv) {
|
||||
newArgv = sliceArgv(newArgv)
|
||||
|
||||
if (app.ipcReady) {
|
||||
log('Second app instance opened, but was prevented:', newArgv)
|
||||
windows.main.show()
|
||||
|
||||
processArgv(newArgv)
|
||||
} else {
|
||||
argv.push(...newArgv)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading args.
|
||||
// Production: 1 arg, eg: /Applications/WebTorrent.app/Contents/MacOS/WebTorrent
|
||||
// Development: 2 args, eg: electron .
|
||||
// Test: 4 args, eg: electron -r .../mocks.js .
|
||||
function sliceArgv (argv) {
|
||||
return argv.slice(
|
||||
config.IS_PRODUCTION
|
||||
? 1
|
||||
: config.IS_TEST
|
||||
? 4
|
||||
: 2
|
||||
)
|
||||
}
|
||||
|
||||
function processArgv (argv) {
|
||||
const torrentIds = []
|
||||
argv.forEach(arg => {
|
||||
if (arg === '-n' || arg === '-o' || arg === '-u') {
|
||||
// Critical path: Only load the 'dialog' package if it is needed
|
||||
const dialog = require('./dialog')
|
||||
if (arg === '-n') {
|
||||
dialog.openSeedDirectory()
|
||||
} else if (arg === '-o') {
|
||||
dialog.openTorrentFile()
|
||||
} else if (arg === '-u') {
|
||||
dialog.openTorrentAddress()
|
||||
}
|
||||
} else if (arg === '--hidden') {
|
||||
// Ignore hidden argument, already being handled
|
||||
} else if (arg.startsWith('-psn')) {
|
||||
// Ignore Mac launchd "process serial number" argument
|
||||
// Issue: https://github.com/leenkx/leenkxbox/issues/214
|
||||
} else if (arg.startsWith('--')) {
|
||||
// Ignore Spectron flags
|
||||
} else if (arg === 'data:,') {
|
||||
// Ignore weird Spectron argument
|
||||
} else if (arg !== '.') {
|
||||
// Ignore '.' argument, which gets misinterpreted as a torrent id, when a
|
||||
// development copy of WebTorrent is started while a production version is
|
||||
// running.
|
||||
torrentIds.push(arg)
|
||||
}
|
||||
})
|
||||
if (torrentIds.length > 0) {
|
||||
windows.main.dispatch('onOpen', torrentIds)
|
||||
}
|
||||
}
|
248
src/main/ipc.js
Normal file
@ -0,0 +1,248 @@
|
||||
module.exports = {
|
||||
init,
|
||||
setModule
|
||||
}
|
||||
|
||||
const { app, ipcMain } = require('electron')
|
||||
|
||||
const log = require('./log')
|
||||
const menu = require('./menu')
|
||||
const windows = require('./windows')
|
||||
|
||||
// Messages from the main process, to be sent once the WebTorrent process starts
|
||||
const messageQueueMainToWebTorrent = []
|
||||
|
||||
// Will hold modules injected from the app that will be used on fired
|
||||
// IPC events.
|
||||
const modules = {}
|
||||
|
||||
function setModule (name, module) {
|
||||
modules[name] = module
|
||||
}
|
||||
|
||||
function init () {
|
||||
ipcMain.once('ipcReady', e => {
|
||||
app.ipcReady = true
|
||||
app.emit('ipcReady')
|
||||
})
|
||||
|
||||
ipcMain.once('ipcReadyWebTorrent', e => {
|
||||
app.ipcReadyWebTorrent = true
|
||||
log('sending %d queued messages from the main win to the webtorrent window',
|
||||
messageQueueMainToWebTorrent.length)
|
||||
messageQueueMainToWebTorrent.forEach(message => {
|
||||
windows.webtorrent.send(message.name, ...message.args)
|
||||
log('webtorrent: sent queued %s', message.name)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Dialog
|
||||
*/
|
||||
|
||||
ipcMain.on('openTorrentFile', () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openTorrentFile()
|
||||
})
|
||||
ipcMain.on('openFiles', () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openFiles()
|
||||
})
|
||||
|
||||
/**
|
||||
* Dock
|
||||
*/
|
||||
|
||||
ipcMain.on('setBadge', (e, ...args) => {
|
||||
const dock = require('./dock')
|
||||
dock.setBadge(...args)
|
||||
})
|
||||
ipcMain.on('downloadFinished', (e, ...args) => {
|
||||
const dock = require('./dock')
|
||||
dock.downloadFinished(...args)
|
||||
})
|
||||
|
||||
/**
|
||||
* Player Events
|
||||
*/
|
||||
|
||||
ipcMain.on('onPlayerOpen', () => {
|
||||
const powerSaveBlocker = require('./power-save-blocker')
|
||||
const shortcuts = require('./shortcuts')
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
menu.togglePlaybackControls(true)
|
||||
powerSaveBlocker.enable()
|
||||
shortcuts.enable()
|
||||
thumbar.enable()
|
||||
})
|
||||
|
||||
ipcMain.on('onPlayerUpdate', (e, ...args) => {
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
menu.onPlayerUpdate(...args)
|
||||
thumbar.onPlayerUpdate(...args)
|
||||
})
|
||||
|
||||
ipcMain.on('onPlayerClose', () => {
|
||||
const powerSaveBlocker = require('./power-save-blocker')
|
||||
const shortcuts = require('./shortcuts')
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
menu.togglePlaybackControls(false)
|
||||
powerSaveBlocker.disable()
|
||||
shortcuts.disable()
|
||||
thumbar.disable()
|
||||
})
|
||||
|
||||
ipcMain.on('onPlayerPlay', () => {
|
||||
const powerSaveBlocker = require('./power-save-blocker')
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
powerSaveBlocker.enable()
|
||||
thumbar.onPlayerPlay()
|
||||
})
|
||||
|
||||
ipcMain.on('onPlayerPause', () => {
|
||||
const powerSaveBlocker = require('./power-save-blocker')
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
powerSaveBlocker.disable()
|
||||
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
|
||||
*/
|
||||
|
||||
ipcMain.on('openPath', (e, ...args) => {
|
||||
const shell = require('./shell')
|
||||
shell.openPath(...args)
|
||||
})
|
||||
ipcMain.on('showItemInFolder', (e, ...args) => {
|
||||
const shell = require('./shell')
|
||||
shell.showItemInFolder(...args)
|
||||
})
|
||||
ipcMain.on('moveItemToTrash', (e, ...args) => {
|
||||
const shell = require('./shell')
|
||||
shell.moveItemToTrash(...args)
|
||||
})
|
||||
|
||||
/**
|
||||
* File handlers
|
||||
*/
|
||||
|
||||
ipcMain.on('setDefaultFileHandler', (e, flag) => {
|
||||
const handlers = require('./handlers')
|
||||
|
||||
if (flag) handlers.install()
|
||||
else handlers.uninstall()
|
||||
})
|
||||
|
||||
/**
|
||||
* Auto start on login
|
||||
*/
|
||||
|
||||
ipcMain.on('setStartup', (e, flag) => {
|
||||
const startup = require('./startup')
|
||||
|
||||
if (flag) startup.install()
|
||||
else startup.uninstall()
|
||||
})
|
||||
|
||||
/**
|
||||
* Windows: Main
|
||||
*/
|
||||
|
||||
const main = windows.main
|
||||
|
||||
ipcMain.on('setAspectRatio', (e, ...args) => main.setAspectRatio(...args))
|
||||
ipcMain.on('setBounds', (e, ...args) => main.setBounds(...args))
|
||||
ipcMain.on('setProgress', (e, ...args) => main.setProgress(...args))
|
||||
ipcMain.on('setTitle', (e, ...args) => main.setTitle(...args))
|
||||
ipcMain.on('show', () => main.show())
|
||||
ipcMain.on('toggleFullScreen', (e, ...args) => main.toggleFullScreen(...args))
|
||||
ipcMain.on('setAllowNav', (e, ...args) => menu.setAllowNav(...args))
|
||||
|
||||
/**
|
||||
* External Media Player
|
||||
*/
|
||||
|
||||
ipcMain.on('checkForExternalPlayer', (e, path) => {
|
||||
const externalPlayer = require('./external-player')
|
||||
|
||||
externalPlayer.checkInstall(path, err => {
|
||||
windows.main.send('checkForExternalPlayer', !err)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('openExternalPlayer', (e, ...args) => {
|
||||
const externalPlayer = require('./external-player')
|
||||
const shortcuts = require('./shortcuts')
|
||||
const thumbar = require('./thumbar')
|
||||
|
||||
menu.togglePlaybackControls(false)
|
||||
shortcuts.disable()
|
||||
thumbar.disable()
|
||||
externalPlayer.spawn(...args)
|
||||
})
|
||||
|
||||
ipcMain.on('quitExternalPlayer', () => {
|
||||
const externalPlayer = require('./external-player')
|
||||
externalPlayer.kill()
|
||||
})
|
||||
|
||||
/**
|
||||
* Message passing
|
||||
*/
|
||||
|
||||
const oldEmit = ipcMain.emit
|
||||
ipcMain.emit = (name, e, ...args) => {
|
||||
// Relay messages between the main window and the Leenkx Box Background Window
|
||||
if (name.startsWith('wt-') && !app.isQuitting) {
|
||||
console.dir(e.sender.getTitle())
|
||||
if (e.sender.getTitle() === 'Leenkx Box Background Window') {
|
||||
// Send message to main window
|
||||
windows.main.send(name, ...args)
|
||||
log('webtorrent: got %s', name)
|
||||
} else if (app.ipcReadyWebTorrent) {
|
||||
// Send message to webtorrent window
|
||||
windows.webtorrent.send(name, ...args)
|
||||
log('webtorrent: sent %s', name)
|
||||
} else {
|
||||
// Queue message for webtorrent window, it hasn't finished loading yet
|
||||
messageQueueMainToWebTorrent.push({
|
||||
name,
|
||||
args
|
||||
})
|
||||
log('webtorrent: queueing %s', name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Emit all other events normally
|
||||
oldEmit.call(ipcMain, name, e, ...args)
|
||||
}
|
||||
}
|
28
src/main/log.js
Normal file
@ -0,0 +1,28 @@
|
||||
module.exports = log
|
||||
module.exports.error = error
|
||||
|
||||
/**
|
||||
* In the main electron process, we do not use console.log() statements because they do
|
||||
* not show up in a convenient location when running the packaged (i.e. production)
|
||||
* version of the app. Instead use this module, which sends the logs to the main window
|
||||
* where they can be viewed in Developer Tools.
|
||||
*/
|
||||
|
||||
const { app } = require('electron')
|
||||
const windows = require('./windows')
|
||||
|
||||
function log (...args) {
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('log', ...args)
|
||||
} else {
|
||||
app.once('ipcReady', () => windows.main.send('log', ...args))
|
||||
}
|
||||
}
|
||||
|
||||
function error (...args) {
|
||||
if (app.ipcReady) {
|
||||
windows.main.send('error', ...args)
|
||||
} else {
|
||||
app.once('ipcReady', () => windows.main.send('error', ...args))
|
||||
}
|
||||
}
|
468
src/main/menu.js
Normal file
@ -0,0 +1,468 @@
|
||||
module.exports = {
|
||||
init,
|
||||
togglePlaybackControls,
|
||||
setWindowFocus,
|
||||
setAllowNav,
|
||||
onPlayerUpdate,
|
||||
onToggleAlwaysOnTop,
|
||||
onToggleFullScreen
|
||||
}
|
||||
|
||||
const { app, Menu } = require('electron')
|
||||
|
||||
const config = require('../config')
|
||||
const windows = require('./windows')
|
||||
|
||||
let menu = null
|
||||
|
||||
function init () {
|
||||
menu = Menu.buildFromTemplate(getMenuTemplate())
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
function togglePlaybackControls (flag) {
|
||||
getMenuItem('Play/Pause').enabled = flag
|
||||
getMenuItem('Skip Next').enabled = flag
|
||||
getMenuItem('Skip Previous').enabled = flag
|
||||
getMenuItem('Increase Volume').enabled = flag
|
||||
getMenuItem('Decrease Volume').enabled = flag
|
||||
getMenuItem('Step Forward').enabled = flag
|
||||
getMenuItem('Step Backward').enabled = flag
|
||||
getMenuItem('Increase Speed').enabled = flag
|
||||
getMenuItem('Decrease Speed').enabled = flag
|
||||
getMenuItem('Add Subtitles File...').enabled = flag
|
||||
|
||||
if (flag === false) {
|
||||
getMenuItem('Skip Next').enabled = false
|
||||
getMenuItem('Skip Previous').enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPlayerUpdate (hasNext, hasPrevious) {
|
||||
getMenuItem('Skip Next').enabled = hasNext
|
||||
getMenuItem('Skip Previous').enabled = hasPrevious
|
||||
}
|
||||
|
||||
function setWindowFocus (flag) {
|
||||
getMenuItem('Full Screen').enabled = flag
|
||||
getMenuItem('Float on Top').enabled = flag
|
||||
}
|
||||
|
||||
// Disallow opening more screens on top of the current one.
|
||||
function setAllowNav (flag) {
|
||||
getMenuItem('Preferences').enabled = flag
|
||||
if (process.platform === 'darwin') {
|
||||
getMenuItem('Create New Torrent...').enabled = flag
|
||||
} else {
|
||||
getMenuItem('Create New Torrent from Folder...').enabled = flag
|
||||
getMenuItem('Create New Torrent from File...').enabled = flag
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleAlwaysOnTop (flag) {
|
||||
getMenuItem('Float on Top').checked = flag
|
||||
}
|
||||
|
||||
function onToggleFullScreen (flag) {
|
||||
getMenuItem('Full Screen').checked = flag
|
||||
}
|
||||
|
||||
function getMenuItem (label) {
|
||||
for (const menuItem of menu.items) {
|
||||
const submenuItem = menuItem.submenu.items.find(item => item.label === label)
|
||||
if (submenuItem) return submenuItem
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
const template = [
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: process.platform === 'darwin'
|
||||
? 'Create New Torrent...'
|
||||
: 'Create New Torrent from Folder...',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openSeedDirectory()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent File...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openTorrentFile()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Open Torrent Address...',
|
||||
accelerator: 'CmdOrCtrl+U',
|
||||
click: () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openTorrentAddress()
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'close'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{
|
||||
role: 'undo'
|
||||
},
|
||||
{
|
||||
role: 'redo'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'cut'
|
||||
},
|
||||
{
|
||||
role: 'copy'
|
||||
},
|
||||
{
|
||||
label: 'Paste Torrent Address',
|
||||
role: 'paste'
|
||||
},
|
||||
{
|
||||
role: 'delete'
|
||||
},
|
||||
{
|
||||
role: 'selectall'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Full Screen',
|
||||
type: 'checkbox',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Ctrl+Command+F'
|
||||
: 'F11',
|
||||
click: () => windows.main.toggleFullScreen()
|
||||
},
|
||||
{
|
||||
label: 'Float on Top',
|
||||
type: 'checkbox',
|
||||
click: () => windows.main.toggleAlwaysOnTop()
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Go Back',
|
||||
accelerator: 'Esc',
|
||||
click: () => windows.main.dispatch('escapeBack')
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Developer',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Developer Tools',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Alt+Command+I'
|
||||
: 'Ctrl+Shift+I',
|
||||
click: () => windows.main.toggleDevTools()
|
||||
},
|
||||
{
|
||||
label: 'Show WebTorrent Process',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'Alt+Command+P'
|
||||
: 'Ctrl+Shift+P',
|
||||
click: () => windows.webtorrent.toggleDevTools()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Playback',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Play/Pause',
|
||||
accelerator: 'Space',
|
||||
click: () => windows.main.dispatch('playPause'),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Skip Next',
|
||||
accelerator: 'N',
|
||||
click: () => windows.main.dispatch('nextTrack'),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Skip Previous',
|
||||
accelerator: 'P',
|
||||
click: () => windows.main.dispatch('previousTrack'),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Increase Volume',
|
||||
accelerator: 'CmdOrCtrl+Up',
|
||||
click: () => windows.main.dispatch('changeVolume', 0.1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Decrease Volume',
|
||||
accelerator: 'CmdOrCtrl+Down',
|
||||
click: () => windows.main.dispatch('changeVolume', -0.1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Step Forward',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Right'
|
||||
: 'Alt+Right',
|
||||
click: () => windows.main.dispatch('skip', 10),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Step Backward',
|
||||
accelerator: process.platform === 'darwin'
|
||||
? 'CmdOrCtrl+Alt+Left'
|
||||
: 'Alt+Left',
|
||||
click: () => windows.main.dispatch('skip', -10),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Increase Speed',
|
||||
accelerator: 'CmdOrCtrl+=',
|
||||
click: () => windows.main.dispatch('changePlaybackRate', 1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: 'Decrease Speed',
|
||||
accelerator: 'CmdOrCtrl+-',
|
||||
click: () => windows.main.dispatch('changePlaybackRate', -1),
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Add Subtitles File...',
|
||||
click: () => windows.main.dispatch('openSubtitles'),
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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',
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn more about ' + config.APP_NAME,
|
||||
click: () => {
|
||||
const shell = require('./shell')
|
||||
shell.openExternal(config.HOME_PAGE_URL)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Release Notes',
|
||||
click: () => {
|
||||
const shell = require('./shell')
|
||||
shell.openExternal(config.GITHUB_URL_RELEASES)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Contribute on GitHub',
|
||||
click: () => {
|
||||
const shell = require('./shell')
|
||||
shell.openExternal(config.GITHUB_URL)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Report an Issue...',
|
||||
click: () => {
|
||||
const shell = require('./shell')
|
||||
shell.openExternal(config.GITHUB_URL_ISSUES)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Follow us on Twitter',
|
||||
click: () => {
|
||||
const shell = require('./shell')
|
||||
shell.openExternal(config.TWITTER_PAGE_URL)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// WebTorrent menu (Mac)
|
||||
template.unshift({
|
||||
label: config.APP_NAME,
|
||||
submenu: [
|
||||
{
|
||||
role: 'about'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => windows.main.dispatch('preferences')
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'services'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'hide'
|
||||
},
|
||||
{
|
||||
role: 'hideothers'
|
||||
},
|
||||
{
|
||||
role: 'unhide'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'quit'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Edit menu (Mac)
|
||||
template[2].submenu.push(
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [
|
||||
{
|
||||
role: 'startspeaking'
|
||||
},
|
||||
{
|
||||
role: 'stopspeaking'
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
// Window menu (Mac)
|
||||
template.splice(6, 0, {
|
||||
role: 'window',
|
||||
submenu: [
|
||||
{
|
||||
role: 'minimize'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'front'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// On Windows and Linux, open dialogs do not support selecting both files and
|
||||
// folders and files, so add an extra menu item so there is one for each type.
|
||||
if (process.platform === 'linux' || process.platform === 'win32') {
|
||||
// File menu (Windows, Linux)
|
||||
template[0].submenu.unshift({
|
||||
label: 'Create New Torrent from File...',
|
||||
click: () => {
|
||||
const dialog = require('./dialog')
|
||||
dialog.openSeedFile()
|
||||
}
|
||||
})
|
||||
|
||||
// Edit menu (Windows, Linux)
|
||||
template[1].submenu.push(
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => windows.main.dispatch('preferences')
|
||||
})
|
||||
|
||||
// Help menu (Windows, Linux)
|
||||
template[5].submenu.push(
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'About ' + config.APP_NAME,
|
||||
click: () => windows.about.init()
|
||||
}
|
||||
)
|
||||
}
|
||||
// Add "File > Quit" menu item so Linux distros where the system tray icon is
|
||||
// missing will have a way to quit the app.
|
||||
if (process.platform === 'linux') {
|
||||
// File menu (Linux)
|
||||
template[0].submenu.push({
|
||||
label: 'Quit',
|
||||
click: () => app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
34
src/main/power-save-blocker.js
Normal file
@ -0,0 +1,34 @@
|
||||
module.exports = {
|
||||
enable,
|
||||
disable
|
||||
}
|
||||
|
||||
const { powerSaveBlocker } = require('electron')
|
||||
const log = require('./log')
|
||||
|
||||
let blockId = 0
|
||||
|
||||
/**
|
||||
* Block the system from entering low-power (sleep) mode or turning off the
|
||||
* display.
|
||||
*/
|
||||
function enable () {
|
||||
if (powerSaveBlocker.isStarted(blockId)) {
|
||||
// If a power saver block already exists, do nothing.
|
||||
return
|
||||
}
|
||||
blockId = powerSaveBlocker.start('prevent-display-sleep')
|
||||
log(`powerSaveBlocker.enable: ${blockId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop blocking the system from entering low-power mode.
|
||||
*/
|
||||
function disable () {
|
||||
if (!powerSaveBlocker.isStarted(blockId)) {
|
||||
// If a power saver block does not exist, do nothing.
|
||||
return
|
||||
}
|
||||
powerSaveBlocker.stop(blockId)
|
||||
log(`powerSaveBlocker.disable: ${blockId}`)
|
||||
}
|
42
src/main/shell.js
Normal file
@ -0,0 +1,42 @@
|
||||
module.exports = {
|
||||
openExternal,
|
||||
openPath,
|
||||
showItemInFolder,
|
||||
moveItemToTrash
|
||||
}
|
||||
|
||||
const { shell } = require('electron')
|
||||
const log = require('./log')
|
||||
|
||||
/**
|
||||
* Open the given external protocol URL in the desktop’s default manner.
|
||||
*/
|
||||
function openExternal (url) {
|
||||
log(`openExternal: ${url}`)
|
||||
shell.openExternal(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the given file in the desktop’s default manner.
|
||||
*/
|
||||
|
||||
function openPath (path) {
|
||||
log(`openPath: ${path}`)
|
||||
shell.openPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the given file in a file manager. If possible, select the file.
|
||||
*/
|
||||
function showItemInFolder (path) {
|
||||
log(`showItemInFolder: ${path}`)
|
||||
shell.showItemInFolder(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given file to trash and returns a boolean status for the operation.
|
||||
*/
|
||||
function moveItemToTrash (path) {
|
||||
log(`moveItemToTrash: ${path}`)
|
||||
shell.trashItem(path)
|
||||
}
|
30
src/main/shortcuts.js
Normal file
@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
disable,
|
||||
enable
|
||||
}
|
||||
|
||||
const { globalShortcut } = require('electron')
|
||||
const windows = require('./windows')
|
||||
|
||||
function enable () {
|
||||
// Register play/pause media key, available on some keyboards.
|
||||
globalShortcut.register(
|
||||
'MediaPlayPause',
|
||||
() => windows.main.dispatch('playPause')
|
||||
)
|
||||
globalShortcut.register(
|
||||
'MediaNextTrack',
|
||||
() => windows.main.dispatch('nextTrack')
|
||||
)
|
||||
globalShortcut.register(
|
||||
'MediaPreviousTrack',
|
||||
() => windows.main.dispatch('previousTrack')
|
||||
)
|
||||
}
|
||||
|
||||
function disable () {
|
||||
// Return the media key to the OS, so other apps can use it.
|
||||
globalShortcut.unregister('MediaPlayPause')
|
||||
globalShortcut.unregister('MediaNextTrack')
|
||||
globalShortcut.unregister('MediaPreviousTrack')
|
||||
}
|
40
src/main/squirrel-win32.js
Normal file
@ -0,0 +1,40 @@
|
||||
module.exports = {
|
||||
handleEvent
|
||||
}
|
||||
|
||||
const { app } = require('electron')
|
||||
|
||||
const path = require('path')
|
||||
const spawn = require('child_process').spawn
|
||||
|
||||
const handlers = require('./handlers')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function handleEvent (cmd) {
|
||||
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated') {
|
||||
run([`--createShortcut=${EXE_NAME}`], app.quit)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-uninstall') {
|
||||
// Uninstall .torrent file and magnet link handlers
|
||||
handlers.uninstall()
|
||||
|
||||
run([`--removeShortcut=${EXE_NAME}`], app.quit)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === '--squirrel-obsolete') {
|
||||
app.quit()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
28
src/main/startup.js
Normal file
@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
install,
|
||||
uninstall
|
||||
}
|
||||
|
||||
const { APP_NAME } = require('../config')
|
||||
const AutoLaunch = require('auto-launch')
|
||||
|
||||
const appLauncher = new AutoLaunch({
|
||||
name: APP_NAME,
|
||||
isHidden: true
|
||||
})
|
||||
|
||||
function install () {
|
||||
return appLauncher
|
||||
.isEnabled()
|
||||
.then(enabled => {
|
||||
if (!enabled) return appLauncher.enable()
|
||||
})
|
||||
}
|
||||
|
||||
function uninstall () {
|
||||
return appLauncher
|
||||
.isEnabled()
|
||||
.then(enabled => {
|
||||
if (enabled) return appLauncher.disable()
|
||||
})
|
||||
}
|
91
src/main/thumbar.js
Normal file
@ -0,0 +1,91 @@
|
||||
module.exports = {
|
||||
disable,
|
||||
enable,
|
||||
onPlayerPause,
|
||||
onPlayerPlay,
|
||||
onPlayerUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* On Windows, add a "thumbnail toolbar" with a play/pause button in the taskbar.
|
||||
* This provides users a way to access play/pause functionality without restoring
|
||||
* or activating the window.
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const config = require('../config')
|
||||
|
||||
const windows = require('./windows')
|
||||
|
||||
const PREV_ICON = path.join(config.STATIC_PATH, 'PreviousTrackThumbnailBarButton.png')
|
||||
const PLAY_ICON = path.join(config.STATIC_PATH, 'PlayThumbnailBarButton.png')
|
||||
const PAUSE_ICON = path.join(config.STATIC_PATH, 'PauseThumbnailBarButton.png')
|
||||
const NEXT_ICON = path.join(config.STATIC_PATH, 'NextTrackThumbnailBarButton.png')
|
||||
|
||||
// Array indices for each button
|
||||
const PREV = 0
|
||||
const PLAY_PAUSE = 1
|
||||
const NEXT = 2
|
||||
|
||||
let buttons = []
|
||||
|
||||
/**
|
||||
* Show the Windows thumbnail toolbar buttons.
|
||||
*/
|
||||
function enable () {
|
||||
buttons = [
|
||||
{
|
||||
tooltip: 'Previous Track',
|
||||
icon: PREV_ICON,
|
||||
click: () => windows.main.dispatch('previousTrack')
|
||||
},
|
||||
{
|
||||
tooltip: 'Pause',
|
||||
icon: PAUSE_ICON,
|
||||
click: () => windows.main.dispatch('playPause')
|
||||
},
|
||||
{
|
||||
tooltip: 'Next Track',
|
||||
icon: NEXT_ICON,
|
||||
click: () => windows.main.dispatch('nextTrack')
|
||||
}
|
||||
]
|
||||
update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the Windows thumbnail toolbar buttons.
|
||||
*/
|
||||
function disable () {
|
||||
buttons = []
|
||||
update()
|
||||
}
|
||||
|
||||
function onPlayerPause () {
|
||||
if (!isEnabled()) return
|
||||
buttons[PLAY_PAUSE].tooltip = 'Play'
|
||||
buttons[PLAY_PAUSE].icon = PLAY_ICON
|
||||
update()
|
||||
}
|
||||
|
||||
function onPlayerPlay () {
|
||||
if (!isEnabled()) return
|
||||
buttons[PLAY_PAUSE].tooltip = 'Pause'
|
||||
buttons[PLAY_PAUSE].icon = PAUSE_ICON
|
||||
update()
|
||||
}
|
||||
|
||||
function onPlayerUpdate (state) {
|
||||
if (!isEnabled()) return
|
||||
buttons[PREV].flags = [state.hasPrevious ? 'enabled' : 'disabled']
|
||||
buttons[NEXT].flags = [state.hasNext ? 'enabled' : 'disabled']
|
||||
update()
|
||||
}
|
||||
|
||||
function isEnabled () {
|
||||
return buttons.length > 0
|
||||
}
|
||||
|
||||
function update () {
|
||||
windows.main.win.setThumbarButtons(buttons)
|
||||
}
|
103
src/main/tray.js
Normal file
@ -0,0 +1,103 @@
|
||||
module.exports = {
|
||||
hasTray,
|
||||
init,
|
||||
setWindowFocus
|
||||
}
|
||||
|
||||
const { app, Tray, Menu } = require('electron')
|
||||
|
||||
const config = require('../config')
|
||||
const windows = require('./windows')
|
||||
|
||||
let tray
|
||||
|
||||
function init () {
|
||||
if (process.platform === 'linux') {
|
||||
initLinux()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
initWin32()
|
||||
}
|
||||
// Mac apps generally do not have menu bar icons
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there a tray icon is active.
|
||||
*/
|
||||
function hasTray () {
|
||||
return !!tray
|
||||
}
|
||||
|
||||
function setWindowFocus (flag) {
|
||||
if (!tray) return
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function initLinux () {
|
||||
checkLinuxTraySupport(err => {
|
||||
if (!err) createTray()
|
||||
})
|
||||
}
|
||||
|
||||
function initWin32 () {
|
||||
createTray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for libappindicator support before creating tray icon.
|
||||
*/
|
||||
function checkLinuxTraySupport (cb) {
|
||||
const cp = require('child_process')
|
||||
|
||||
// Check that libappindicator libraries are installed in system.
|
||||
cp.exec('ldconfig -p | grep libappindicator', (err, stdout) => {
|
||||
if (err) return cb(err)
|
||||
cb(null)
|
||||
})
|
||||
}
|
||||
|
||||
function createTray () {
|
||||
tray = new Tray(getIconPath())
|
||||
|
||||
// On Windows, left click opens the app, right click opens the context menu.
|
||||
// On Linux, any click (left or right) opens the context menu.
|
||||
tray.on('click', () => windows.main.show())
|
||||
|
||||
// Show the tray context menu, and keep the available commands up to date
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function updateTrayMenu () {
|
||||
const contextMenu = Menu.buildFromTemplate(getMenuTemplate())
|
||||
tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
function getMenuTemplate () {
|
||||
return [
|
||||
getToggleItem(),
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => app.quit()
|
||||
}
|
||||
]
|
||||
|
||||
function getToggleItem () {
|
||||
if (windows.main.win.isVisible()) {
|
||||
return {
|
||||
label: 'Hide to tray',
|
||||
click: () => windows.main.hide()
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
label: 'Show WebTorrent',
|
||||
click: () => windows.main.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
77
src/main/updater.js
Normal file
@ -0,0 +1,77 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
const { autoUpdater } = require('electron')
|
||||
const get = require('simple-get')
|
||||
|
||||
const config = require('../config')
|
||||
const log = require('./log')
|
||||
const windows = require('./windows')
|
||||
|
||||
const AUTO_UPDATE_URL = config.AUTO_UPDATE_URL +
|
||||
'?version=' + config.APP_VERSION +
|
||||
'&platform=' + process.platform +
|
||||
'&sysarch=' + config.OS_SYSARCH
|
||||
|
||||
function init () {
|
||||
if (process.platform === 'linux') {
|
||||
initLinux()
|
||||
} else {
|
||||
initDarwinWin32()
|
||||
}
|
||||
}
|
||||
|
||||
// The Electron auto-updater does not support Linux yet, so manually check for
|
||||
// updates and show the user a modal notification.
|
||||
function initLinux () {
|
||||
get.concat(AUTO_UPDATE_URL, onResponse)
|
||||
}
|
||||
|
||||
function onResponse (err, res, data) {
|
||||
if (err) return log(`Update error: ${err.message}`)
|
||||
if (res.statusCode === 200) {
|
||||
// Update available
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (err) {
|
||||
return log(`Update error: Invalid JSON response: ${err.message}`)
|
||||
}
|
||||
windows.main.dispatch('updateAvailable', data.version)
|
||||
} else if (res.statusCode === 204) {
|
||||
// No update available
|
||||
} else {
|
||||
// Unexpected status code
|
||||
log(`Update error: Unexpected status code: ${res.statusCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
function initDarwinWin32 () {
|
||||
autoUpdater.on(
|
||||
'error',
|
||||
(err) => log.error(`Update error: ${err.message}`)
|
||||
)
|
||||
|
||||
autoUpdater.on(
|
||||
'checking-for-update',
|
||||
() => log('Checking for update')
|
||||
)
|
||||
|
||||
autoUpdater.on(
|
||||
'update-available',
|
||||
() => log('Update available')
|
||||
)
|
||||
|
||||
autoUpdater.on(
|
||||
'update-not-available',
|
||||
() => log('No update available')
|
||||
)
|
||||
|
||||
autoUpdater.on(
|
||||
'update-downloaded',
|
||||
(e, notes, name, date, url) => log(`Update downloaded: ${name}: ${url}`)
|
||||
)
|
||||
|
||||
autoUpdater.setFeedURL({ url: AUTO_UPDATE_URL })
|
||||
autoUpdater.checkForUpdates()
|
||||
}
|
41
src/main/user-tasks.js
Normal file
@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
init
|
||||
}
|
||||
|
||||
const { app } = require('electron')
|
||||
|
||||
/**
|
||||
* Add a user task menu to the app icon on right-click. (Windows)
|
||||
*/
|
||||
function init () {
|
||||
if (process.platform !== 'win32') return
|
||||
app.setUserTasks(getUserTasks())
|
||||
}
|
||||
|
||||
function getUserTasks () {
|
||||
return [
|
||||
{
|
||||
arguments: '-n',
|
||||
title: 'Create New Torrent...',
|
||||
description: 'Create a new torrent'
|
||||
},
|
||||
{
|
||||
arguments: '-o',
|
||||
title: 'Open Torrent File...',
|
||||
description: 'Open a .torrent file'
|
||||
},
|
||||
{
|
||||
arguments: '-u',
|
||||
title: 'Open Torrent Address...',
|
||||
description: 'Open a torrent from a URL'
|
||||
}
|
||||
].map(getUserTasksItem)
|
||||
}
|
||||
|
||||
function getUserTasksItem (item) {
|
||||
return Object.assign(item, {
|
||||
program: process.execPath,
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0
|
||||
})
|
||||
}
|
57
src/main/windows/about.js
Normal file
@ -0,0 +1,57 @@
|
||||
const about = module.exports = {
|
||||
init,
|
||||
win: null
|
||||
}
|
||||
|
||||
const config = require('../../config')
|
||||
const { BrowserWindow } = require('electron')
|
||||
|
||||
function init () {
|
||||
if (about.win) {
|
||||
return about.win.show()
|
||||
}
|
||||
|
||||
const win = about.win = new BrowserWindow({
|
||||
backgroundColor: '#ECECEC',
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
height: 250,
|
||||
icon: getIconPath(),
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: 'About ' + config.APP_WINDOW_TITLE,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
enableBlinkFeatures: 'AudioVideoTracks',
|
||||
enableRemoteModule: true,
|
||||
backgroundThrottling: false
|
||||
},
|
||||
width: 300
|
||||
})
|
||||
require('@electron/remote/main').enable(win.webContents)
|
||||
|
||||
win.loadURL(config.WINDOW_ABOUT)
|
||||
|
||||
win.once('ready-to-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', () => {
|
||||
about.win = null
|
||||
})
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
3
src/main/windows/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
exports.about = require('./about')
|
||||
exports.main = require('./main')
|
||||
exports.webtorrent = require('./webtorrent')
|
260
src/main/windows/main.js
Normal file
@ -0,0 +1,260 @@
|
||||
const main = module.exports = {
|
||||
dispatch,
|
||||
hide,
|
||||
init,
|
||||
send,
|
||||
setAspectRatio,
|
||||
setBounds,
|
||||
setProgress,
|
||||
setTitle,
|
||||
show,
|
||||
toggleAlwaysOnTop,
|
||||
toggleDevTools,
|
||||
toggleFullScreen,
|
||||
win: null
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, screen } = require('electron')
|
||||
const debounce = require('debounce')
|
||||
|
||||
const config = require('../../config')
|
||||
const log = require('../log')
|
||||
const menu = require('../menu')
|
||||
|
||||
function init (state, options) {
|
||||
if (main.win) {
|
||||
return main.win.show()
|
||||
}
|
||||
|
||||
const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds)
|
||||
|
||||
const win = main.win = new BrowserWindow({
|
||||
backgroundColor: '#282828',
|
||||
darkTheme: true, // Forces dark theme (GTK+3)
|
||||
height: initialBounds.height,
|
||||
icon: getIconPath(), // Window icon (Windows, Linux)
|
||||
minHeight: config.WINDOW_MIN_HEIGHT,
|
||||
minWidth: config.WINDOW_MIN_WIDTH,
|
||||
show: false,
|
||||
title: config.APP_WINDOW_TITLE,
|
||||
titleBarStyle: 'hiddenInset', // Hide title bar (Mac)
|
||||
useContentSize: true, // Specify web page size without OS chrome
|
||||
width: initialBounds.width,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
enableBlinkFeatures: 'AudioVideoTracks',
|
||||
enableRemoteModule: true,
|
||||
backgroundThrottling: false
|
||||
},
|
||||
x: initialBounds.x,
|
||||
y: initialBounds.y
|
||||
})
|
||||
require('@electron/remote/main').enable(win.webContents)
|
||||
|
||||
win.loadURL(config.WINDOW_MAIN)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!options.hidden) win.show()
|
||||
})
|
||||
|
||||
if (win.setSheetOffset) {
|
||||
win.setSheetOffset(config.UI_HEADER_HEIGHT)
|
||||
}
|
||||
|
||||
win.webContents.on('dom-ready', () => {
|
||||
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('focus', onWindowFocus)
|
||||
|
||||
win.on('hide', onWindowBlur)
|
||||
win.on('show', onWindowFocus)
|
||||
|
||||
win.on('enter-full-screen', () => {
|
||||
menu.onToggleFullScreen(true)
|
||||
send('fullscreenChanged', true)
|
||||
win.setMenuBarVisibility(false)
|
||||
})
|
||||
|
||||
win.on('leave-full-screen', () => {
|
||||
menu.onToggleFullScreen(false)
|
||||
send('fullscreenChanged', false)
|
||||
win.setMenuBarVisibility(true)
|
||||
})
|
||||
|
||||
win.on('move', debounce(e => {
|
||||
send('windowBoundsChanged', main.win.getBounds())
|
||||
}, 1000))
|
||||
|
||||
win.on('resize', debounce(e => {
|
||||
send('windowBoundsChanged', main.win.getBounds())
|
||||
}, 1000))
|
||||
|
||||
win.on('close', e => {
|
||||
if (process.platform !== 'darwin') {
|
||||
const tray = require('../tray')
|
||||
if (!tray.hasTray()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!app.isQuitting) {
|
||||
e.preventDefault()
|
||||
hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function dispatch (...args) {
|
||||
send('dispatch', ...args)
|
||||
}
|
||||
|
||||
function hide () {
|
||||
if (!main.win) return
|
||||
dispatch('backToList')
|
||||
main.win.hide()
|
||||
}
|
||||
|
||||
function send (...args) {
|
||||
if (!main.win) return
|
||||
main.win.send(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce window aspect ratio. Remove with 0. (Mac)
|
||||
*/
|
||||
function setAspectRatio (aspectRatio) {
|
||||
if (!main.win) return
|
||||
main.win.setAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the size of the window.
|
||||
* TODO: Clean this up? Seems overly complicated.
|
||||
*/
|
||||
function setBounds (bounds, maximize) {
|
||||
// Do nothing in fullscreen
|
||||
if (!main.win || main.win.isFullScreen()) {
|
||||
log('setBounds: not setting bounds because already in full screen mode')
|
||||
return
|
||||
}
|
||||
|
||||
// Maximize or minimize, if the second argument is present
|
||||
if (maximize === true && !main.win.isMaximized()) {
|
||||
log('setBounds: maximizing')
|
||||
main.win.maximize()
|
||||
} else if (maximize === false && main.win.isMaximized()) {
|
||||
log('setBounds: minimizing')
|
||||
main.win.unmaximize()
|
||||
}
|
||||
|
||||
const willBeMaximized = typeof maximize === 'boolean' ? maximize : main.win.isMaximized()
|
||||
// Assuming we're not maximized or maximizing, set the window size
|
||||
if (!willBeMaximized) {
|
||||
log(`setBounds: setting bounds to ${JSON.stringify(bounds)}`)
|
||||
if (bounds.x === null && bounds.y === null) {
|
||||
// X and Y not specified? By default, center on current screen
|
||||
const scr = screen.getDisplayMatching(main.win.getBounds())
|
||||
bounds.x = Math.round(scr.bounds.x + (scr.bounds.width / 2) - (bounds.width / 2))
|
||||
bounds.y = Math.round(scr.bounds.y + (scr.bounds.height / 2) - (bounds.height / 2))
|
||||
log(`setBounds: centered to ${JSON.stringify(bounds)}`)
|
||||
}
|
||||
// Resize the window's content area (so window border doesn't need to be taken
|
||||
// into account)
|
||||
if (bounds.contentBounds) {
|
||||
main.win.setContentBounds(bounds, true)
|
||||
} else {
|
||||
main.win.setBounds(bounds, true)
|
||||
}
|
||||
} else {
|
||||
log('setBounds: not setting bounds because of window maximization')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress bar to [0, 1]. Indeterminate when > 1. Remove with < 0.
|
||||
*/
|
||||
function setProgress (progress) {
|
||||
if (!main.win) return
|
||||
main.win.setProgressBar(progress)
|
||||
}
|
||||
|
||||
function setTitle (title) {
|
||||
if (!main.win) return
|
||||
main.win.setTitle(title)
|
||||
}
|
||||
|
||||
function show () {
|
||||
if (!main.win) return
|
||||
main.win.show()
|
||||
}
|
||||
|
||||
// Sets whether the window should always show on top of other windows
|
||||
function toggleAlwaysOnTop (flag) {
|
||||
if (!main.win) return
|
||||
if (flag == null) {
|
||||
flag = !main.win.isAlwaysOnTop()
|
||||
}
|
||||
log(`toggleAlwaysOnTop ${flag}`)
|
||||
main.win.setAlwaysOnTop(flag)
|
||||
menu.onToggleAlwaysOnTop(flag)
|
||||
}
|
||||
|
||||
function toggleDevTools () {
|
||||
if (!main.win) return
|
||||
log('toggleDevTools')
|
||||
if (main.win.webContents.isDevToolsOpened()) {
|
||||
main.win.webContents.closeDevTools()
|
||||
} else {
|
||||
main.win.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullScreen (flag) {
|
||||
if (!main.win || !main.win.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flag == null) flag = !main.win.isFullScreen()
|
||||
|
||||
log(`toggleFullScreen ${flag}`)
|
||||
|
||||
if (flag) {
|
||||
// Fullscreen and aspect ratio do not play well together. (Mac)
|
||||
main.win.setAspectRatio(0)
|
||||
}
|
||||
|
||||
main.win.setFullScreen(flag)
|
||||
}
|
||||
|
||||
function onWindowBlur () {
|
||||
menu.setWindowFocus(false)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
const tray = require('../tray')
|
||||
tray.setWindowFocus(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowFocus () {
|
||||
menu.setWindowFocus(true)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
const tray = require('../tray')
|
||||
tray.setWindowFocus(true)
|
||||
}
|
||||
}
|
||||
|
||||
function getIconPath () {
|
||||
return process.platform === 'win32'
|
||||
? config.APP_ICON + '.ico'
|
||||
: config.APP_ICON + '.png'
|
||||
}
|
68
src/main/windows/webtorrent.js
Normal file
@ -0,0 +1,68 @@
|
||||
const webtorrent = module.exports = {
|
||||
init,
|
||||
send,
|
||||
show,
|
||||
toggleDevTools,
|
||||
win: null
|
||||
}
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
|
||||
const config = require('../../config')
|
||||
|
||||
function init () {
|
||||
const win = webtorrent.win = new BrowserWindow({
|
||||
backgroundColor: '#1E1E1E',
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
fullscreenable: false,
|
||||
height: 150,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
skipTaskbar: true,
|
||||
title: 'webtorrent-hidden-window',
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
enableBlinkFeatures: 'AudioVideoTracks',
|
||||
enableRemoteModule: true,
|
||||
backgroundThrottling: false
|
||||
},
|
||||
width: 150
|
||||
})
|
||||
require('@electron/remote/main').enable(win.webContents)
|
||||
|
||||
win.loadURL(config.WINDOW_WEBTORRENT)
|
||||
|
||||
// Prevent killing the WebTorrent process
|
||||
win.on('close', e => {
|
||||
if (app.isQuitting) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
win.hide()
|
||||
})
|
||||
}
|
||||
|
||||
function show () {
|
||||
if (!webtorrent.win) return
|
||||
webtorrent.win.show()
|
||||
}
|
||||
|
||||
function send (...args) {
|
||||
if (!webtorrent.win) return
|
||||
webtorrent.win.send(...args)
|
||||
}
|
||||
|
||||
function toggleDevTools () {
|
||||
if (!webtorrent.win) return
|
||||
if (webtorrent.win.webContents.isDevToolsOpened()) {
|
||||
webtorrent.win.webContents.closeDevTools()
|
||||
webtorrent.win.hide()
|
||||
} else {
|
||||
webtorrent.win.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
}
|
26
src/renderer/components/create-torrent-error-page.js
Normal file
@ -0,0 +1,26 @@
|
||||
const React = require('react')
|
||||
|
||||
const { dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class CreateTorrentErrorPage extends React.Component {
|
||||
render () {
|
||||
return (
|
||||
<div className='create-torrent'>
|
||||
<h2>Create torrent</h2>
|
||||
<p className='torrent-info'>
|
||||
<p>
|
||||
Sorry, you must select at least one file that is not a hidden file.
|
||||
</p>
|
||||
<p>
|
||||
Hidden files, starting with a . character, are not included.
|
||||
</p>
|
||||
</p>
|
||||
<p className='float-right'>
|
||||
<button className='button-flat light' onClick={dispatcher('cancel')}>
|
||||
Cancel
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
31
src/renderer/components/delete-all-torrents-modal.js
Normal file
@ -0,0 +1,31 @@
|
||||
const React = require('react')
|
||||
|
||||
const ModalOKCancel = require('./modal-ok-cancel')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = 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')
|
||||
}
|
||||
}
|
||||
}
|
68
src/renderer/components/header.js
Normal file
@ -0,0 +1,68 @@
|
||||
const React = require('react')
|
||||
|
||||
const { dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
class Header extends React.Component {
|
||||
render () {
|
||||
const loc = this.props.state.location
|
||||
return (
|
||||
<div
|
||||
className='header'
|
||||
onMouseMove={dispatcher('mediaMouseMoved')}
|
||||
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
|
||||
onMouseLeave={dispatcher('mediaControlsMouseLeave')}
|
||||
role='navigation'
|
||||
>
|
||||
{this.getTitle()}
|
||||
<div className='nav left float-left'>
|
||||
<i
|
||||
className={'icon back ' + (loc.hasBack() ? '' : 'disabled')}
|
||||
title='Back'
|
||||
onClick={dispatcher('back')}
|
||||
role='button'
|
||||
aria-disabled={!loc.hasBack()}
|
||||
aria-label='Back'
|
||||
>
|
||||
chevron_left
|
||||
</i>
|
||||
<i
|
||||
className={'icon forward ' + (loc.hasForward() ? '' : 'disabled')}
|
||||
title='Forward'
|
||||
onClick={dispatcher('forward')}
|
||||
role='button'
|
||||
aria-disabled={!loc.hasForward()}
|
||||
aria-label='Forward'
|
||||
>
|
||||
chevron_right
|
||||
</i>
|
||||
</div>
|
||||
<div className='nav right float-right'>
|
||||
{this.getAddButton()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
getTitle () {
|
||||
if (process.platform !== 'darwin') return null
|
||||
const state = this.props.state
|
||||
return (<div className='title ellipsis'>{state.window.title}</div>)
|
||||
}
|
||||
|
||||
getAddButton () {
|
||||
const state = this.props.state
|
||||
if (state.location.url() !== 'home') return null
|
||||
return (
|
||||
<i
|
||||
className='icon add'
|
||||
title='Add torrent'
|
||||
onClick={dispatcher('openFiles')}
|
||||
role='button'
|
||||
>
|
||||
add
|
||||
</i>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Header
|
35
src/renderer/components/heading.js
Normal file
@ -0,0 +1,35 @@
|
||||
const React = require('react')
|
||||
const PropTypes = require('prop-types')
|
||||
|
||||
const colors = require('material-ui/styles/colors')
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Heading
|
27
src/renderer/components/modal-ok-cancel.js
Normal file
@ -0,0 +1,27 @@
|
||||
const React = require('react')
|
||||
const FlatButton = require('material-ui/FlatButton').default
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
|
||||
module.exports = class ModalOKCancel extends React.Component {
|
||||
render () {
|
||||
const cancelStyle = { marginRight: 10, color: 'black' }
|
||||
const { cancelText, onCancel, okText, onOK } = this.props
|
||||
return (
|
||||
<div className='float-right'>
|
||||
<FlatButton
|
||||
className='control cancel'
|
||||
style={cancelStyle}
|
||||
label={cancelText}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<RaisedButton
|
||||
className='control ok'
|
||||
primary
|
||||
label={okText}
|
||||
onClick={onOK}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
51
src/renderer/components/open-torrent-address-modal.js
Normal file
@ -0,0 +1,51 @@
|
||||
const React = require('react')
|
||||
const TextField = require('material-ui/TextField').default
|
||||
const { clipboard } = require('electron')
|
||||
|
||||
const ModalOKCancel = require('./modal-ok-cancel')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
const { isMagnetLink } = require('../lib/torrent-player')
|
||||
|
||||
module.exports = class OpenTorrentAddressModal extends React.Component {
|
||||
render () {
|
||||
return (
|
||||
<div className='open-torrent-address-modal'>
|
||||
<p><label>Enter torrent address or magnet link</label></p>
|
||||
<div>
|
||||
<TextField
|
||||
id='torrent-address-field'
|
||||
className='control'
|
||||
ref={(c) => { this.torrentURL = c }}
|
||||
fullWidth
|
||||
onKeyDown={handleKeyDown.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
<ModalOKCancel
|
||||
cancelText='CANCEL'
|
||||
onCancel={dispatcher('exitModal')}
|
||||
okText='OK'
|
||||
onOK={handleOK.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.torrentURL.input.focus()
|
||||
const clipboardContent = clipboard.readText()
|
||||
|
||||
if (isMagnetLink(clipboardContent)) {
|
||||
this.torrentURL.input.value = clipboardContent
|
||||
this.torrentURL.input.select()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown (e) {
|
||||
if (e.which === 13) handleOK.call(this) /* hit Enter to submit */
|
||||
}
|
||||
|
||||
function handleOK () {
|
||||
dispatch('exitModal')
|
||||
dispatch('addTorrent', this.torrentURL.input.value)
|
||||
}
|
85
src/renderer/components/path-selector.js
Normal file
@ -0,0 +1,85 @@
|
||||
const path = require('path')
|
||||
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const remote = require('@electron/remote')
|
||||
const React = require('react')
|
||||
const PropTypes = require('prop-types')
|
||||
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
const TextField = require('material-ui/TextField').default
|
||||
|
||||
// Lets you pick a file or directory.
|
||||
// Uses the system Open File dialog.
|
||||
// You can't edit the text field directly.
|
||||
class PathSelector extends React.Component {
|
||||
static 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PathSelector
|
31
src/renderer/components/remove-torrent-modal.js
Normal file
@ -0,0 +1,31 @@
|
||||
const React = require('react')
|
||||
|
||||
const ModalOKCancel = require('./modal-ok-cancel')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class RemoveTorrentModal extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
const message = state.modal.deleteData
|
||||
? 'Are you sure you want to remove this torrent from the list and delete the data file?'
|
||||
: 'Are you sure you want to remove this torrent from the list?'
|
||||
const buttonText = state.modal.deleteData ? 'REMOVE DATA' : 'REMOVE'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p><strong>{message}</strong></p>
|
||||
<ModalOKCancel
|
||||
cancelText='CANCEL'
|
||||
onCancel={dispatcher('exitModal')}
|
||||
okText={buttonText}
|
||||
onOK={handleRemove}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
function handleRemove () {
|
||||
dispatch('deleteTorrent', state.modal.infoHash, state.modal.deleteData)
|
||||
dispatch('exitModal')
|
||||
}
|
||||
}
|
||||
}
|
55
src/renderer/components/show-more.js
Normal file
@ -0,0 +1,55 @@
|
||||
const React = require('react')
|
||||
const PropTypes = require('prop-types')
|
||||
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
|
||||
class ShowMore extends React.Component {
|
||||
static get propTypes () {
|
||||
return {
|
||||
defaultExpanded: PropTypes.bool,
|
||||
hideLabel: PropTypes.string,
|
||||
showLabel: PropTypes.string
|
||||
}
|
||||
}
|
||||
|
||||
static get defaultProps () {
|
||||
return {
|
||||
hideLabel: 'Hide more...',
|
||||
showLabel: 'Show more...'
|
||||
}
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
expanded: !!this.props.defaultExpanded
|
||||
}
|
||||
|
||||
this.handleClick = this.handleClick.bind(this)
|
||||
}
|
||||
|
||||
handleClick () {
|
||||
this.setState({
|
||||
expanded: !this.state.expanded
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
const label = this.state.expanded
|
||||
? this.props.hideLabel
|
||||
: this.props.showLabel
|
||||
return (
|
||||
<div className='show-more' style={this.props.style}>
|
||||
{this.state.expanded ? this.props.children : null}
|
||||
<RaisedButton
|
||||
className='control'
|
||||
onClick={this.handleClick}
|
||||
label={label}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShowMore
|
45
src/renderer/components/unsupported-media-modal.js
Normal file
@ -0,0 +1,45 @@
|
||||
const React = require('react')
|
||||
const { shell } = require('electron')
|
||||
|
||||
const ModalOKCancel = require('./modal-ok-cancel')
|
||||
const { dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class UnsupportedMediaModal extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
const err = state.modal.error
|
||||
const message = (err && err.getMessage)
|
||||
? err.getMessage()
|
||||
: err
|
||||
const onAction = state.modal.externalPlayerInstalled
|
||||
? dispatcher('openExternalPlayer')
|
||||
: () => this.onInstall()
|
||||
const actionText = state.modal.externalPlayerInstalled
|
||||
? 'PLAY IN ' + state.getExternalPlayerName().toUpperCase()
|
||||
: 'INSTALL VLC'
|
||||
const errorMessage = state.modal.externalPlayerNotFound
|
||||
? 'Couldn\'t run external player. Please make sure it\'s installed.'
|
||||
: ''
|
||||
return (
|
||||
<div>
|
||||
<p><strong>Sorry, we can't play that file.</strong></p>
|
||||
<p>{message}</p>
|
||||
<ModalOKCancel
|
||||
cancelText='CANCEL'
|
||||
onCancel={dispatcher('backToList')}
|
||||
okText={actionText}
|
||||
onOK={onAction}
|
||||
/>
|
||||
<p className='error-text'>{errorMessage}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onInstall () {
|
||||
shell.openExternal('https://www.videolan.org/vlc/')
|
||||
|
||||
// TODO: dcposch send a dispatch rather than modifying state directly
|
||||
const state = this.props.state
|
||||
state.modal.externalPlayerInstalled = true // Assume they'll install it successfully
|
||||
}
|
||||
}
|
37
src/renderer/components/update-available-modal.js
Normal file
@ -0,0 +1,37 @@
|
||||
const React = require('react')
|
||||
const { shell } = require('electron')
|
||||
|
||||
const ModalOKCancel = require('./modal-ok-cancel')
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class UpdateAvailableModal extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
return (
|
||||
<div className='update-available-modal'>
|
||||
<p><strong>A new version of WebTorrent is available: v{state.modal.version}</strong></p>
|
||||
<p>
|
||||
We have an auto-updater for Windows and Mac.
|
||||
We don't have one for Linux yet, so you'll have to download the new version manually.
|
||||
</p>
|
||||
<ModalOKCancel
|
||||
cancelText='SKIP THIS RELEASE'
|
||||
onCancel={handleSkip}
|
||||
okText='SHOW DOWNLOAD PAGE'
|
||||
onOK={handleShow}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
function handleShow () {
|
||||
// TODO: use the GitHub urls from config.js
|
||||
shell.openExternal('https://github.com/leenkx/leenkxbox/releases')
|
||||
dispatch('exitModal')
|
||||
}
|
||||
|
||||
function handleSkip () {
|
||||
dispatch('skipVersion', state.modal.version)
|
||||
dispatch('exitModal')
|
||||
}
|
||||
}
|
||||
}
|
17
src/renderer/controllers/audio-tracks-controller.js
Normal file
@ -0,0 +1,17 @@
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = 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
|
||||
}
|
||||
}
|
13
src/renderer/controllers/folder-watcher-controller.js
Normal file
@ -0,0 +1,13 @@
|
||||
const { ipcRenderer } = require('electron')
|
||||
|
||||
module.exports = class FolderWatcherController {
|
||||
start () {
|
||||
console.log('-- IPC: start folder watcher')
|
||||
ipcRenderer.send('startFolderWatcher')
|
||||
}
|
||||
|
||||
stop () {
|
||||
console.log('-- IPC: stop folder watcher')
|
||||
ipcRenderer.send('stopFolderWatcher')
|
||||
}
|
||||
}
|
80
src/renderer/controllers/media-controller.js
Normal file
@ -0,0 +1,80 @@
|
||||
const { ipcRenderer } = require('electron')
|
||||
const telemetry = require('../lib/telemetry')
|
||||
const Playlist = require('../lib/playlist')
|
||||
|
||||
// Controls local play back: the <video>/<audio> tag and VLC
|
||||
// Does not control remote casting (Chromecast etc)
|
||||
module.exports = class MediaController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
mediaSuccess () {
|
||||
telemetry.logPlayAttempt('success')
|
||||
}
|
||||
|
||||
mediaStalled () {
|
||||
this.state.playing.isStalled = true
|
||||
}
|
||||
|
||||
mediaError (error) {
|
||||
const state = this.state
|
||||
if (state.location.url() === 'player') {
|
||||
telemetry.logPlayAttempt('error')
|
||||
state.playing.location = 'error'
|
||||
ipcRenderer.send('checkForExternalPlayer', state.saved.prefs.externalPlayerPath)
|
||||
ipcRenderer.once('checkForExternalPlayer', (e, isInstalled) => {
|
||||
state.modal = {
|
||||
id: 'unsupported-media-modal',
|
||||
error,
|
||||
externalPlayerInstalled: isInstalled
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mediaTimeUpdate () {
|
||||
this.state.playing.lastTimeUpdate = new Date().getTime()
|
||||
this.state.playing.isStalled = false
|
||||
}
|
||||
|
||||
mediaMouseMoved () {
|
||||
this.state.playing.mouseStationarySince = new Date().getTime()
|
||||
}
|
||||
|
||||
controlsMouseEnter () {
|
||||
this.state.playing.mouseInControls = true
|
||||
this.state.playing.mouseStationarySince = new Date().getTime()
|
||||
}
|
||||
|
||||
controlsMouseLeave () {
|
||||
this.state.playing.mouseInControls = false
|
||||
this.state.playing.mouseStationarySince = new Date().getTime()
|
||||
}
|
||||
|
||||
openExternalPlayer () {
|
||||
const state = this.state
|
||||
state.playing.location = 'external'
|
||||
|
||||
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 () {
|
||||
const modal = this.state.modal
|
||||
if (modal && modal.id === 'unsupported-media-modal') {
|
||||
modal.externalPlayerNotFound = true
|
||||
}
|
||||
}
|
||||
}
|
395
src/renderer/controllers/playback-controller.js
Normal file
@ -0,0 +1,395 @@
|
||||
const { ipcRenderer } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const Cast = require('../lib/cast')
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
const telemetry = require('../lib/telemetry')
|
||||
const { UnplayableFileError, UnplayableTorrentError } = require('../lib/errors')
|
||||
const sound = require('../lib/sound')
|
||||
const TorrentPlayer = require('../lib/torrent-player')
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const Playlist = require('../lib/playlist')
|
||||
const State = require('../lib/state')
|
||||
|
||||
// Controls playback of torrents and files within torrents
|
||||
// both local (<video>,<audio>,external player) and remote (cast)
|
||||
module.exports = class PlaybackController {
|
||||
constructor (state, config, update) {
|
||||
this.state = state
|
||||
this.config = config
|
||||
this.update = update
|
||||
}
|
||||
|
||||
// Play a file in a torrent.
|
||||
// * Start torrenting, if necessary
|
||||
// * Stream, if not already fully downloaded
|
||||
// * If no file index is provided, restore the most recently viewed file or autoplay the first
|
||||
playFile (infoHash, index /* optional */) {
|
||||
this.pauseActiveTorrents(infoHash)
|
||||
|
||||
const state = this.state
|
||||
if (state.location.url() === 'player') {
|
||||
this.updatePlayer(infoHash, index, false, (err) => {
|
||||
if (err) dispatch('error', err)
|
||||
else this.play()
|
||||
})
|
||||
} else {
|
||||
let initialized = false
|
||||
state.location.go({
|
||||
url: 'player',
|
||||
setup: (cb) => {
|
||||
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
|
||||
|
||||
if (index === undefined || initialized) index = torrentSummary.mostRecentFileIndex
|
||||
if (index === undefined) index = torrentSummary.files.findIndex(TorrentPlayer.isPlayable)
|
||||
if (index === undefined) return cb(new UnplayableTorrentError())
|
||||
|
||||
initialized = true
|
||||
|
||||
this.openPlayer(infoHash, index, (err) => {
|
||||
if (!err) this.play()
|
||||
cb(err)
|
||||
})
|
||||
},
|
||||
destroy: () => this.closePlayer()
|
||||
}, (err) => {
|
||||
if (err) dispatch('error', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Open a file in OS default app.
|
||||
openPath (infoHash, index) {
|
||||
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
|
||||
const filePath = path.join(
|
||||
torrentSummary.path,
|
||||
torrentSummary.files[index].path)
|
||||
ipcRenderer.send('openPath', filePath)
|
||||
}
|
||||
|
||||
// Toggle (play or pause) the currently playing media
|
||||
playPause () {
|
||||
const state = this.state
|
||||
if (state.location.url() !== 'player') return
|
||||
|
||||
// force rerendering if window is hidden,
|
||||
// in order to bypass `raf` and play/pause media immediately
|
||||
const mediaTag = document.querySelector('video,audio')
|
||||
if (!state.window.isVisible && mediaTag) {
|
||||
if (state.playing.isPaused) mediaTag.play()
|
||||
else mediaTag.pause()
|
||||
}
|
||||
|
||||
if (state.playing.isPaused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
pauseActiveTorrents (infoHash) {
|
||||
// Playback Priority: pause all active torrents if needed.
|
||||
if (!this.state.saved.prefs.highestPlaybackPriority) return
|
||||
|
||||
// Do not pause active torrents if playing a fully downloaded torrent.
|
||||
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
|
||||
if (torrentSummary.status === 'seeding') return
|
||||
|
||||
dispatch('prioritizeTorrent', infoHash)
|
||||
}
|
||||
|
||||
// Play next file in list (if any)
|
||||
nextTrack () {
|
||||
const state = this.state
|
||||
if (Playlist.hasNext(state) && state.playing.location !== 'external') {
|
||||
this.updatePlayer(
|
||||
state.playing.infoHash, Playlist.getNextIndex(state), false, (err) => {
|
||||
if (err) dispatch('error', err)
|
||||
else this.play()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Play previous track in list (if any)
|
||||
previousTrack () {
|
||||
const state = this.state
|
||||
if (Playlist.hasPrevious(state) && state.playing.location !== 'external') {
|
||||
this.updatePlayer(
|
||||
state.playing.infoHash, Playlist.getPreviousIndex(state), false, (err) => {
|
||||
if (err) dispatch('error', err)
|
||||
else this.play()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Play (unpause) the current media
|
||||
play () {
|
||||
const state = this.state
|
||||
if (!state.playing.isPaused) return
|
||||
state.playing.isPaused = false
|
||||
if (isCasting(state)) {
|
||||
Cast.play()
|
||||
}
|
||||
ipcRenderer.send('onPlayerPlay')
|
||||
}
|
||||
|
||||
// Pause the currently playing media
|
||||
pause () {
|
||||
const state = this.state
|
||||
if (state.playing.isPaused) return
|
||||
state.playing.isPaused = true
|
||||
if (isCasting(state)) {
|
||||
Cast.pause()
|
||||
}
|
||||
ipcRenderer.send('onPlayerPause')
|
||||
}
|
||||
|
||||
// Skip specified number of seconds (backwards if negative)
|
||||
skip (time) {
|
||||
this.skipTo(this.state.playing.currentTime + time)
|
||||
}
|
||||
|
||||
// Skip (aka seek) to a specific point, in seconds
|
||||
skipTo (time) {
|
||||
if (!Number.isFinite(time)) {
|
||||
console.error('Tried to skip to a non-finite time ' + time)
|
||||
return console.trace()
|
||||
}
|
||||
if (isCasting(this.state)) Cast.seek(time)
|
||||
else this.state.playing.jumpToTime = time
|
||||
}
|
||||
|
||||
// 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
|
||||
// Playback speed ranges from 16 (fast forward) to 1 (normal playback)
|
||||
// to 0.25 (quarter-speed playback), then goes to -0.25, -0.5, -1, -2, etc
|
||||
// until -16 (fast rewind)
|
||||
changePlaybackRate (direction) {
|
||||
const state = this.state
|
||||
let rate = state.playing.playbackRate
|
||||
if (direction > 0 && rate < 2) {
|
||||
rate += 0.25
|
||||
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
|
||||
rate -= 0.25
|
||||
} else if (direction > 0 && rate >= 1 && rate < 16) {
|
||||
rate *= 2
|
||||
} else if (direction < 0 && rate > 1 && rate <= 16) {
|
||||
rate /= 2
|
||||
}
|
||||
state.playing.playbackRate = rate
|
||||
if (isCasting(state) && !Cast.setRate(rate)) {
|
||||
state.playing.playbackRate = 1
|
||||
}
|
||||
// Wait a bit before we hide the controls and header again
|
||||
state.playing.mouseStationarySince = new Date().getTime()
|
||||
}
|
||||
|
||||
// Change the volume, in range [0, 1], by some amount
|
||||
// For example, volume muted (0), changeVolume (0.3) increases to 30% volume
|
||||
changeVolume (delta) {
|
||||
// change volume with delta value
|
||||
this.setVolume(this.state.playing.volume + delta)
|
||||
}
|
||||
|
||||
// Set the volume to some value in [0, 1]
|
||||
setVolume (volume) {
|
||||
// check if its in [0.0 - 1.0] range
|
||||
volume = Math.max(0, Math.min(1, volume))
|
||||
|
||||
const state = this.state
|
||||
if (isCasting(state)) {
|
||||
Cast.setVolume(volume)
|
||||
} else {
|
||||
state.playing.setVolume = volume
|
||||
}
|
||||
}
|
||||
|
||||
// Hide player controls while playing video, if the mouse stays still for a while
|
||||
// Never hide the controls when:
|
||||
// * The mouse is over the controls or we're scrubbing (see CSS)
|
||||
// * The video is paused
|
||||
// * The video is playing remotely on Chromecast or Airplay
|
||||
showOrHidePlayerControls () {
|
||||
const state = this.state
|
||||
const hideControls = state.shouldHidePlayerControls()
|
||||
|
||||
if (hideControls !== state.playing.hideControls) {
|
||||
state.playing.hideControls = hideControls
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Opens the video player to a specific torrent
|
||||
openPlayer (infoHash, index, cb) {
|
||||
const state = this.state
|
||||
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
|
||||
|
||||
state.playing.infoHash = torrentSummary.infoHash
|
||||
state.playing.isReady = false
|
||||
|
||||
// update UI to show pending playback
|
||||
sound.play('PLAY')
|
||||
|
||||
this.startServer(torrentSummary)
|
||||
ipcRenderer.send('onPlayerOpen')
|
||||
this.updatePlayer(infoHash, index, true, cb)
|
||||
}
|
||||
|
||||
// Starts WebTorrent server for media streaming
|
||||
startServer (torrentSummary) {
|
||||
const state = this.state
|
||||
|
||||
if (torrentSummary.status === 'paused') {
|
||||
dispatch('startTorrentingSummary', torrentSummary.torrentKey)
|
||||
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash,
|
||||
() => onTorrentReady())
|
||||
} else {
|
||||
onTorrentReady()
|
||||
}
|
||||
|
||||
function onTorrentReady () {
|
||||
ipcRenderer.send('wt-start-server', torrentSummary.infoHash)
|
||||
ipcRenderer.once('wt-server-running', () => { state.playing.isReady = true })
|
||||
}
|
||||
}
|
||||
|
||||
// Called each time the current file changes
|
||||
updatePlayer (infoHash, index, resume, cb) {
|
||||
const state = this.state
|
||||
|
||||
const torrentSummary = TorrentSummary.getByKey(state, infoHash)
|
||||
const fileSummary = torrentSummary.files[index]
|
||||
|
||||
if (!TorrentPlayer.isPlayable(fileSummary)) {
|
||||
torrentSummary.mostRecentFileIndex = undefined
|
||||
return cb(new UnplayableFileError())
|
||||
}
|
||||
|
||||
torrentSummary.mostRecentFileIndex = index
|
||||
|
||||
// update state
|
||||
state.playing.infoHash = infoHash
|
||||
state.playing.fileIndex = index
|
||||
state.playing.fileName = fileSummary.name
|
||||
state.playing.type = TorrentPlayer.isVideo(fileSummary)
|
||||
? 'video'
|
||||
: TorrentPlayer.isAudio(fileSummary)
|
||||
? 'audio'
|
||||
: 'other'
|
||||
|
||||
// pick up where we left off
|
||||
let jumpToTime = 0
|
||||
if (resume && fileSummary.currentTime) {
|
||||
const fraction = fileSummary.currentTime / fileSummary.duration
|
||||
const secondsLeft = fileSummary.duration - fileSummary.currentTime
|
||||
if (fraction < 0.9 && secondsLeft > 10) {
|
||||
jumpToTime = fileSummary.currentTime
|
||||
}
|
||||
}
|
||||
state.playing.jumpToTime = jumpToTime
|
||||
|
||||
// if it's audio, parse out the metadata (artist, title, etc)
|
||||
if (torrentSummary.status === 'paused') {
|
||||
ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, getAudioMetadata)
|
||||
} else {
|
||||
getAudioMetadata()
|
||||
}
|
||||
|
||||
function getAudioMetadata () {
|
||||
if (state.playing.type === 'audio') {
|
||||
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
|
||||
}
|
||||
}
|
||||
|
||||
// if it's video, check for subtitles files that are done downloading
|
||||
dispatch('checkForSubtitles')
|
||||
|
||||
// enable previously selected subtitle track
|
||||
if (fileSummary.selectedSubtitle) {
|
||||
dispatch('addSubtitles', [fileSummary.selectedSubtitle], true)
|
||||
}
|
||||
|
||||
state.window.title = fileSummary.name
|
||||
|
||||
// play in VLC if set as default player (Preferences / Playback / Play in VLC)
|
||||
if (this.state.saved.prefs.openExternalPlayer) {
|
||||
dispatch('openExternalPlayer')
|
||||
this.update()
|
||||
cb()
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise, play the video
|
||||
this.update()
|
||||
|
||||
ipcRenderer.send('onPlayerUpdate', Playlist.hasNext(state), Playlist.hasPrevious(state))
|
||||
cb()
|
||||
}
|
||||
|
||||
closePlayer () {
|
||||
console.log('closePlayer')
|
||||
|
||||
// Quit any external players, like Chromecast/Airplay/etc or VLC
|
||||
const state = this.state
|
||||
if (isCasting(state)) {
|
||||
Cast.stop()
|
||||
}
|
||||
if (state.playing.location === 'external') {
|
||||
ipcRenderer.send('quitExternalPlayer')
|
||||
}
|
||||
|
||||
// Save volume (this session only, not in state.saved)
|
||||
state.previousVolume = state.playing.volume
|
||||
|
||||
if (!state.playing.isReady) telemetry.logPlayAttempt('abandoned') // user gave up waiting
|
||||
|
||||
// Reset the window contents back to the home screen
|
||||
state.playing = State.getDefaultPlayState()
|
||||
state.server = null
|
||||
|
||||
// Reset the window size and location back to where it was
|
||||
if (state.window.isFullScreen) {
|
||||
dispatch('toggleFullScreen', false)
|
||||
}
|
||||
restoreBounds(state)
|
||||
|
||||
// Tell the WebTorrent process to kill the torrent-to-HTTP server
|
||||
ipcRenderer.send('wt-stop-server')
|
||||
|
||||
ipcRenderer.send('onPlayerClose')
|
||||
|
||||
// Playback Priority: resume previously paused downloads.
|
||||
if (this.state.saved.prefs.highestPlaybackPriority) {
|
||||
dispatch('resumePausedTorrents')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
38
src/renderer/controllers/prefs-controller.js
Normal file
@ -0,0 +1,38 @@
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
const { ipcRenderer } = require('electron')
|
||||
|
||||
// Controls the Preferences screen
|
||||
module.exports = class PrefsController {
|
||||
constructor (state, config) {
|
||||
this.state = state
|
||||
this.config = config
|
||||
}
|
||||
|
||||
// Goes to the Preferences screen
|
||||
show () {
|
||||
const state = this.state
|
||||
state.location.go({
|
||||
url: 'preferences',
|
||||
setup (cb) {
|
||||
// initialize preferences
|
||||
state.window.title = 'Preferences'
|
||||
ipcRenderer.send('setAllowNav', false)
|
||||
cb()
|
||||
},
|
||||
destroy: () => {
|
||||
ipcRenderer.send('setAllowNav', true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Updates a single property in the saved prefs
|
||||
// For example: updatePreferences('isFileHandler', true)
|
||||
update (property, value) {
|
||||
if (property === 'isFileHandler') ipcRenderer.send('setDefaultFileHandler', value)
|
||||
else if (property === 'startup') ipcRenderer.send('setStartup', value)
|
||||
|
||||
this.state.saved.prefs[property] = value
|
||||
dispatch('stateSaveImmediate')
|
||||
dispatch('checkDownloadPath')
|
||||
}
|
||||
}
|
134
src/renderer/controllers/subtitles-controller.js
Normal file
@ -0,0 +1,134 @@
|
||||
const remote = require('@electron/remote')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const parallel = require('run-parallel')
|
||||
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class SubtitlesController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
openSubtitles () {
|
||||
const filenames = remote.dialog.showOpenDialogSync({
|
||||
title: 'Select a subtitles file.',
|
||||
filters: [{ name: 'Subtitles', extensions: ['vtt', 'srt'] }],
|
||||
properties: ['openFile']
|
||||
})
|
||||
if (!Array.isArray(filenames)) return
|
||||
this.addSubtitles(filenames, true)
|
||||
}
|
||||
|
||||
selectSubtitle (ix) {
|
||||
this.state.playing.subtitles.selectedIndex = ix
|
||||
}
|
||||
|
||||
toggleSubtitlesMenu () {
|
||||
const subtitles = this.state.playing.subtitles
|
||||
subtitles.showMenu = !subtitles.showMenu
|
||||
}
|
||||
|
||||
addSubtitles (files, autoSelect) {
|
||||
// Subtitles are only supported when playing video files
|
||||
if (this.state.playing.type !== 'video') return
|
||||
if (files.length === 0) return
|
||||
const subtitles = this.state.playing.subtitles
|
||||
|
||||
// Read the files concurrently, then add all resulting subtitle tracks
|
||||
const tasks = files.map((file) => (cb) => loadSubtitle(file, cb))
|
||||
parallel(tasks, (err, tracks) => {
|
||||
if (err) return dispatch('error', err)
|
||||
|
||||
// No dupes allowed
|
||||
tracks.forEach((track, i) => {
|
||||
let trackIndex = subtitles.tracks.findIndex((t) =>
|
||||
track.filePath === t.filePath)
|
||||
|
||||
// Add the track
|
||||
if (trackIndex === -1) {
|
||||
trackIndex = subtitles.tracks.push(track) - 1
|
||||
}
|
||||
|
||||
// If we're auto-selecting a track, try to find one in the user's language
|
||||
if (autoSelect && (i === 0 || isSystemLanguage(track.language))) {
|
||||
subtitles.selectedIndex = trackIndex
|
||||
}
|
||||
})
|
||||
|
||||
// Finally, make sure no two tracks have the same label
|
||||
relabelSubtitles(subtitles)
|
||||
})
|
||||
}
|
||||
|
||||
checkForSubtitles () {
|
||||
if (this.state.playing.type !== 'video') return
|
||||
const torrentSummary = this.state.getPlayingTorrentSummary()
|
||||
if (!torrentSummary || !torrentSummary.progress) return
|
||||
|
||||
torrentSummary.progress.files.forEach((fp, ix) => {
|
||||
if (fp.numPieces !== fp.numPiecesPresent) return // ignore incomplete files
|
||||
const file = torrentSummary.files[ix]
|
||||
if (!this.isSubtitle(file.name)) return
|
||||
const filePath = path.join(torrentSummary.path, file.path)
|
||||
this.addSubtitles([filePath], false)
|
||||
})
|
||||
}
|
||||
|
||||
isSubtitle (file) {
|
||||
const name = typeof file === 'string' ? file : file.name
|
||||
const ext = path.extname(name).toLowerCase()
|
||||
return ext === '.srt' || ext === '.vtt'
|
||||
}
|
||||
}
|
||||
|
||||
function loadSubtitle (file, cb) {
|
||||
// Lazy load to keep startup fast
|
||||
const concat = require('simple-concat')
|
||||
const LanguageDetect = require('languagedetect')
|
||||
const srtToVtt = require('srt-to-vtt')
|
||||
|
||||
// Read the .SRT or .VTT file, parse it, add subtitle track
|
||||
const filePath = file.path || file
|
||||
|
||||
const vttStream = fs.createReadStream(filePath).pipe(srtToVtt())
|
||||
|
||||
concat(vttStream, (err, buf) => {
|
||||
if (err) return dispatch('error', 'Can\'t parse subtitles file.')
|
||||
|
||||
// Detect what language the subtitles are in
|
||||
const vttContents = buf.toString().replace(/(.*-->.*)/g, '')
|
||||
let langDetected = (new LanguageDetect()).detect(vttContents, 2)
|
||||
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
|
||||
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
|
||||
|
||||
const track = {
|
||||
buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
|
||||
language: langDetected,
|
||||
label: langDetected,
|
||||
filePath
|
||||
}
|
||||
|
||||
cb(null, track)
|
||||
})
|
||||
}
|
||||
|
||||
// Checks whether a language name like 'English' or 'German' matches the system
|
||||
// language, aka the current locale
|
||||
function isSystemLanguage (language) {
|
||||
const iso639 = require('iso-639-1')
|
||||
const osLangISO = window.navigator.language.split('-')[0] // eg 'en'
|
||||
const langIso = iso639.getCode(language) // eg 'de' if language is 'German'
|
||||
return langIso === osLangISO
|
||||
}
|
||||
|
||||
// Make sure we don't have two subtitle tracks with the same label
|
||||
// Labels each track by language, eg 'German', 'English', 'English 2', ...
|
||||
function relabelSubtitles (subtitles) {
|
||||
const counts = {}
|
||||
subtitles.tracks.forEach(track => {
|
||||
const lang = track.language
|
||||
counts[lang] = (counts[lang] || 0) + 1
|
||||
track.label = counts[lang] > 1 ? (lang + ' ' + counts[lang]) : lang
|
||||
})
|
||||
}
|
186
src/renderer/controllers/torrent-controller.js
Normal file
@ -0,0 +1,186 @@
|
||||
const path = require('path')
|
||||
const { ipcRenderer } = require('electron')
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const sound = require('../lib/sound')
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class TorrentController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
torrentParsed (torrentKey, infoHash, magnetURI) {
|
||||
let torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
console.log('got infohash for %s torrent %s',
|
||||
torrentSummary ? 'existing' : 'new', torrentKey)
|
||||
|
||||
if (!torrentSummary) {
|
||||
const torrents = this.state.saved.torrents
|
||||
|
||||
// Check if an existing (non-active) torrent has the same info hash
|
||||
if (torrents.find((t) => t.infoHash === infoHash)) {
|
||||
ipcRenderer.send('wt-stop-torrenting', infoHash)
|
||||
return dispatch('error', 'Cannot add duplicate torrent')
|
||||
}
|
||||
|
||||
torrentSummary = {
|
||||
torrentKey,
|
||||
status: 'new'
|
||||
}
|
||||
torrents.unshift(torrentSummary)
|
||||
sound.play('ADD')
|
||||
}
|
||||
|
||||
torrentSummary.infoHash = infoHash
|
||||
torrentSummary.magnetURI = magnetURI
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentWarning (torrentKey, message) {
|
||||
console.log('warning for torrent %s: %s', torrentKey, message)
|
||||
}
|
||||
|
||||
torrentError (torrentKey, message) {
|
||||
// TODO: WebTorrent needs semantic errors
|
||||
if (message.startsWith('Cannot add duplicate torrent')) {
|
||||
// Remove infohash from the message
|
||||
message = 'Cannot add duplicate torrent'
|
||||
}
|
||||
dispatch('error', message)
|
||||
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
if (torrentSummary) {
|
||||
console.log('Pausing torrent %s due to error: %s', torrentSummary.infoHash, message)
|
||||
torrentSummary.status = 'paused'
|
||||
dispatch('update')
|
||||
}
|
||||
}
|
||||
|
||||
torrentMetadata (torrentKey, torrentInfo) {
|
||||
// Summarize torrent
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.status = 'downloading'
|
||||
torrentSummary.name = torrentSummary.displayName || torrentInfo.name
|
||||
torrentSummary.path = torrentInfo.path
|
||||
// TODO: make torrentInfo immutable, save separately as torrentSummary.info
|
||||
// For now, check whether torrentSummary.files has already been set:
|
||||
const hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
|
||||
if (!hasDetailedFileInfo) {
|
||||
torrentSummary.files = torrentInfo.files
|
||||
}
|
||||
if (!torrentSummary.selections) {
|
||||
torrentSummary.selections = torrentSummary.files.map((x) => true)
|
||||
}
|
||||
dispatch('update')
|
||||
|
||||
// Save the .torrent file, if it hasn't been saved already
|
||||
if (!torrentSummary.torrentFileName) ipcRenderer.send('wt-save-torrent-file', torrentKey)
|
||||
|
||||
// Auto-generate a poster image, if it hasn't been generated already
|
||||
if (!torrentSummary.posterFileName) ipcRenderer.send('wt-generate-torrent-poster', torrentKey)
|
||||
}
|
||||
|
||||
torrentDone (torrentKey, torrentInfo) {
|
||||
// Update the torrent summary
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.status = 'seeding'
|
||||
|
||||
// Notify the user that a torrent finished, but only if we actually DL'd at least part of it.
|
||||
// Don't notify if we merely finished verifying data files that were already on disk.
|
||||
if (torrentInfo.bytesReceived > 0) {
|
||||
if (!this.state.window.isFocused) {
|
||||
this.state.dock.badge += 1
|
||||
}
|
||||
showDoneNotification(torrentSummary)
|
||||
ipcRenderer.send('downloadFinished', getTorrentPath(torrentSummary))
|
||||
}
|
||||
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentProgress (progressInfo) {
|
||||
// Overall progress across all active torrents, 0 to 1, or -1 to hide the progress bar
|
||||
// Hide progress bar when client has no torrents, or progress is 100%
|
||||
const progress = (!progressInfo.hasActiveTorrents || progressInfo.progress === 1)
|
||||
? -1
|
||||
: progressInfo.progress
|
||||
|
||||
// Show progress bar under the WebTorrent taskbar icon, on OSX
|
||||
this.state.dock.progress = progress
|
||||
|
||||
// Update progress for each individual torrent
|
||||
progressInfo.torrents.forEach((p) => {
|
||||
const torrentSummary = this.getTorrentSummary(p.torrentKey)
|
||||
if (!torrentSummary) {
|
||||
console.log('warning: got progress for missing torrent %s', p.torrentKey)
|
||||
return
|
||||
}
|
||||
torrentSummary.progress = p
|
||||
})
|
||||
|
||||
// TODO: Find an efficient way to re-enable this line, which allows subtitle
|
||||
// files which are completed after a video starts to play to be added
|
||||
// dynamically to the list of subtitles.
|
||||
// checkForSubtitles()
|
||||
}
|
||||
|
||||
torrentFileModtimes (torrentKey, fileModtimes) {
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
if (!torrentSummary) throw new Error('Not saving modtimes for deleted torrent ' + torrentKey)
|
||||
torrentSummary.fileModtimes = fileModtimes
|
||||
dispatch('stateSave')
|
||||
}
|
||||
|
||||
torrentFileSaved (torrentKey, torrentFileName) {
|
||||
console.log('torrent file saved %s: %s', torrentKey, torrentFileName)
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.torrentFileName = torrentFileName
|
||||
dispatch('stateSave')
|
||||
}
|
||||
|
||||
torrentPosterSaved (torrentKey, posterFileName) {
|
||||
const torrentSummary = this.getTorrentSummary(torrentKey)
|
||||
torrentSummary.posterFileName = posterFileName
|
||||
dispatch('stateSave')
|
||||
}
|
||||
|
||||
torrentAudioMetadata (infoHash, index, info) {
|
||||
const torrentSummary = this.getTorrentSummary(infoHash)
|
||||
const fileSummary = torrentSummary.files[index]
|
||||
fileSummary.audioInfo = info
|
||||
dispatch('update')
|
||||
}
|
||||
|
||||
torrentServerRunning (serverInfo) {
|
||||
this.state.server = serverInfo
|
||||
}
|
||||
|
||||
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
||||
// Returns undefined if we don't know that infoHash
|
||||
getTorrentSummary (torrentKey) {
|
||||
return TorrentSummary.getByKey(this.state, torrentKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getTorrentPath (torrentSummary) {
|
||||
let itemPath = TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
if (torrentSummary.files.length > 1) {
|
||||
itemPath = path.dirname(itemPath)
|
||||
}
|
||||
return itemPath
|
||||
}
|
||||
|
||||
function showDoneNotification (torrent) {
|
||||
const notif = new window.Notification('Download Complete', {
|
||||
body: torrent.name,
|
||||
silent: true
|
||||
})
|
||||
|
||||
notif.onclick = () => {
|
||||
ipcRenderer.send('show')
|
||||
}
|
||||
|
||||
// Only play notification sound if player is inactive
|
||||
if (this.state.playing.isPaused) sound.play('DONE')
|
||||
}
|
413
src/renderer/controllers/torrent-list-controller.js
Normal file
@ -0,0 +1,413 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { ipcRenderer, clipboard } = require('electron')
|
||||
const remote = require('@electron/remote')
|
||||
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
const { TorrentKeyNotFoundError } = require('../lib/errors')
|
||||
const sound = require('../lib/sound')
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
|
||||
const instantIoRegex = /^(https:\/\/)?instant\.io\/#/
|
||||
|
||||
// Controls the torrent list: creating, adding, deleting, & manipulating torrents
|
||||
module.exports = class TorrentListController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
// Adds a torrent to the list, starts downloading/seeding.
|
||||
// TorrentID can be a magnet URI, infohash, or torrent file: https://git.io/vik9M
|
||||
addTorrent (torrentId) {
|
||||
if (torrentId.path) {
|
||||
// Use path string instead of W3C File object
|
||||
torrentId = torrentId.path
|
||||
}
|
||||
|
||||
// Trim extra spaces off pasted magnet links
|
||||
if (typeof torrentId === 'string') {
|
||||
torrentId = torrentId.trim()
|
||||
}
|
||||
|
||||
// Allow a leenkx.com/loader link to be pasted
|
||||
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
|
||||
torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
|
||||
}
|
||||
|
||||
const torrentKey = this.state.nextTorrentKey++
|
||||
const path = this.state.saved.prefs.downloadPath
|
||||
|
||||
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
|
||||
|
||||
dispatch('backToList')
|
||||
}
|
||||
|
||||
// Shows the Create Torrent page with options to seed a given file or folder
|
||||
showCreateTorrent (files) {
|
||||
// You can only create torrents from the home screen.
|
||||
if (this.state.location.url() !== 'home') {
|
||||
return dispatch('error', 'Please go back to the torrent list before creating a new torrent.')
|
||||
}
|
||||
|
||||
// Files will either be an array of file objects, which we can send directly
|
||||
// to the create-torrent screen
|
||||
if (files.length === 0 || typeof files[0] !== 'string') {
|
||||
this.state.location.go({
|
||||
url: 'create-torrent',
|
||||
files,
|
||||
setup: (cb) => {
|
||||
this.state.window.title = 'Create New Torrent'
|
||||
cb(null)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ... or it will be an array of mixed file and folder paths. We have to walk
|
||||
// through all the folders and find the files
|
||||
findFilesRecursive(files, (allFiles) => this.showCreateTorrent(allFiles))
|
||||
}
|
||||
|
||||
// Creates a new torrent and start seeeding
|
||||
createTorrent (options) {
|
||||
const state = this.state
|
||||
const torrentKey = state.nextTorrentKey++
|
||||
ipcRenderer.send('wt-create-torrent', torrentKey, options)
|
||||
state.location.cancel()
|
||||
}
|
||||
|
||||
// Starts downloading and/or seeding a given torrentSummary.
|
||||
startTorrentingSummary (torrentKey) {
|
||||
const s = TorrentSummary.getByKey(this.state, torrentKey)
|
||||
if (!s) throw new TorrentKeyNotFoundError(torrentKey)
|
||||
|
||||
// New torrent: give it a path
|
||||
if (!s.path) {
|
||||
// Use Downloads folder by default
|
||||
s.path = this.state.saved.prefs.downloadPath
|
||||
return start()
|
||||
}
|
||||
|
||||
const fileOrFolder = TorrentSummary.getFileOrFolder(s)
|
||||
|
||||
// New torrent: metadata not yet received
|
||||
if (!fileOrFolder) return start()
|
||||
|
||||
// Existing torrent: check that the path is still there
|
||||
fs.stat(fileOrFolder, err => {
|
||||
if (err) {
|
||||
s.error = 'path-missing'
|
||||
dispatch('backToList')
|
||||
return
|
||||
}
|
||||
start()
|
||||
})
|
||||
|
||||
function start () {
|
||||
ipcRenderer.send('wt-start-torrenting',
|
||||
s.torrentKey,
|
||||
TorrentSummary.getTorrentId(s),
|
||||
s.path,
|
||||
s.fileModtimes,
|
||||
s.selections)
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalTrackers (globalTrackers) {
|
||||
ipcRenderer.send('wt-set-global-trackers', globalTrackers)
|
||||
}
|
||||
|
||||
// TODO: use torrentKey, not infoHash
|
||||
toggleTorrent (infoHash) {
|
||||
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
|
||||
if (torrentSummary.status === 'paused') {
|
||||
torrentSummary.status = 'new'
|
||||
this.startTorrentingSummary(torrentSummary.torrentKey)
|
||||
sound.play('ENABLE')
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
|
||||
torrentSummary.selections[index] = !torrentSummary.selections[index]
|
||||
|
||||
// Let the WebTorrent process know to start or stop fetching that file
|
||||
if (torrentSummary.status !== 'paused') {
|
||||
ipcRenderer.send('wt-select-files', infoHash, torrentSummary.selections)
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteTorrent (infoHash, deleteData) {
|
||||
this.state.modal = {
|
||||
id: 'remove-torrent-modal',
|
||||
infoHash,
|
||||
deleteData
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteAllTorrents (deleteData) {
|
||||
this.state.modal = {
|
||||
id: 'delete-all-torrents-modal',
|
||||
deleteData
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use torrentKey, not infoHash
|
||||
deleteTorrent (infoHash, deleteData) {
|
||||
const index = this.state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
|
||||
|
||||
if (index > -1) {
|
||||
const summary = this.state.saved.torrents[index]
|
||||
deleteTorrentFile(summary, deleteData)
|
||||
|
||||
// remove torrent from saved list
|
||||
this.state.saved.torrents.splice(index, 1)
|
||||
dispatch('stateSave')
|
||||
|
||||
// prevent user from going forward to a deleted torrent
|
||||
this.state.location.clearForward('player')
|
||||
sound.play('DELETE')
|
||||
} else {
|
||||
throw new TorrentKeyNotFoundError(infoHash)
|
||||
}
|
||||
}
|
||||
|
||||
deleteAllTorrents (deleteData) {
|
||||
// Go back to list before the current playing torrent is deleted
|
||||
if (this.state.location.url() === 'player') {
|
||||
dispatch('backToList')
|
||||
}
|
||||
|
||||
this.state.saved.torrents.forEach((summary) => deleteTorrentFile(summary, deleteData))
|
||||
|
||||
this.state.saved.torrents = []
|
||||
dispatch('stateSave')
|
||||
|
||||
// prevent user from going forward to a deleted torrent
|
||||
this.state.location.clearForward('player')
|
||||
sound.play('DELETE')
|
||||
}
|
||||
|
||||
toggleSelectTorrent (infoHash) {
|
||||
if (this.state.selectedInfoHash === infoHash) {
|
||||
this.state.selectedInfoHash = null
|
||||
} else {
|
||||
this.state.selectedInfoHash = infoHash
|
||||
}
|
||||
}
|
||||
|
||||
openTorrentContextMenu (infoHash) {
|
||||
const torrentSummary = TorrentSummary.getByKey(this.state, infoHash)
|
||||
const menu = new remote.Menu()
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
label: 'Remove From List',
|
||||
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, false)
|
||||
}))
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
label: 'Remove Data File',
|
||||
click: () => dispatch('confirmDeleteTorrent', torrentSummary.infoHash, true)
|
||||
}))
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
type: 'separator'
|
||||
}))
|
||||
|
||||
if (torrentSummary.files) {
|
||||
menu.append(new remote.MenuItem({
|
||||
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in Folder',
|
||||
click: () => showItemInFolder(torrentSummary)
|
||||
}))
|
||||
menu.append(new remote.MenuItem({
|
||||
type: 'separator'
|
||||
}))
|
||||
}
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
label: 'Copy Magnet Link to Clipboard',
|
||||
click: () => clipboard.writeText(torrentSummary.magnetURI)
|
||||
}))
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
label: 'Copy Leenkx.com Link to Clipboard',
|
||||
click: () => clipboard.writeText(`https://leenkx.com/loader/#${torrentSummary.infoHash}`)
|
||||
}))
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
label: 'Save Torrent File As...',
|
||||
click: () => dispatch('saveTorrentFileAs', torrentSummary.torrentKey),
|
||||
enabled: torrentSummary.torrentFileName != null
|
||||
}))
|
||||
|
||||
menu.append(new remote.MenuItem({
|
||||
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
|
||||
// Calls `cb` on success, calls `onError` on failure
|
||||
function findFilesRecursive (paths, cb_) {
|
||||
if (paths.length > 1) {
|
||||
let numComplete = 0
|
||||
const ret = []
|
||||
paths.forEach(path => {
|
||||
findFilesRecursive([path], fileObjs => {
|
||||
ret.push(...fileObjs)
|
||||
if (++numComplete === paths.length) {
|
||||
ret.sort((a, b) => a.path < b.path ? -1 : Number(a.path > b.path))
|
||||
cb_(ret)
|
||||
}
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fileOrFolder = paths[0]
|
||||
fs.stat(fileOrFolder, (err, stat) => {
|
||||
if (err) return dispatch('error', err)
|
||||
|
||||
// Files: return name, path, and size
|
||||
if (!stat.isDirectory()) {
|
||||
const filePath = fileOrFolder
|
||||
return cb_([{
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
size: stat.size
|
||||
}])
|
||||
}
|
||||
|
||||
// Folders: recurse, make a list of all the files
|
||||
const folderPath = fileOrFolder
|
||||
fs.readdir(folderPath, (err, fileNames) => {
|
||||
if (err) return dispatch('error', err)
|
||||
const paths = fileNames.map((fileName) => path.join(folderPath, fileName))
|
||||
findFilesRecursive(paths, cb_)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function deleteFile (path) {
|
||||
if (!path) return
|
||||
fs.unlink(path, err => {
|
||||
if (err) dispatch('error', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete all files in a torrent
|
||||
function moveItemToTrash (torrentSummary) {
|
||||
const filePath = TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
if (filePath) ipcRenderer.send('moveItemToTrash', filePath)
|
||||
}
|
||||
|
||||
function showItemInFolder (torrentSummary) {
|
||||
ipcRenderer.send('showItemInFolder', TorrentSummary.getFileOrFolder(torrentSummary))
|
||||
}
|
||||
|
||||
function deleteTorrentFile (torrentSummary, deleteData) {
|
||||
ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash)
|
||||
|
||||
// remove torrent and poster file
|
||||
deleteFile(TorrentSummary.getTorrentPath(torrentSummary))
|
||||
deleteFile(TorrentSummary.getPosterPath(torrentSummary))
|
||||
|
||||
// optionally delete the torrent data
|
||||
if (deleteData) moveItemToTrash(torrentSummary)
|
||||
}
|
26
src/renderer/controllers/update-controller.js
Normal file
@ -0,0 +1,26 @@
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
|
||||
// Controls the UI checking for new versions of the app, prompting install
|
||||
module.exports = class UpdateController {
|
||||
constructor (state) {
|
||||
this.state = state
|
||||
}
|
||||
|
||||
// Shows a modal saying that we have an update
|
||||
updateAvailable (version) {
|
||||
const skipped = this.state.saved.skippedVersions
|
||||
if (skipped && skipped.includes(version)) {
|
||||
console.log('new version skipped by user: v' + version)
|
||||
return
|
||||
}
|
||||
this.state.modal = { id: 'update-available-modal', version }
|
||||
}
|
||||
|
||||
// Don't show the modal again until the next version
|
||||
skipVersion (version) {
|
||||
let skipped = this.state.saved.skippedVersions
|
||||
if (!skipped) skipped = this.state.saved.skippedVersions = []
|
||||
skipped.push(version)
|
||||
dispatch('stateSave')
|
||||
}
|
||||
}
|
527
src/renderer/lib/cast.js
Normal file
@ -0,0 +1,527 @@
|
||||
// The Cast module talks to Airplay and Chromecast
|
||||
// * Modifies state when things change
|
||||
// * Starts and stops casting, provides remote video controls
|
||||
module.exports = {
|
||||
init,
|
||||
toggleMenu,
|
||||
selectDevice,
|
||||
stop,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setVolume,
|
||||
setRate
|
||||
}
|
||||
|
||||
const http = require('http')
|
||||
|
||||
const config = require('../../config')
|
||||
const { CastingError } = require('./errors')
|
||||
|
||||
// Lazy load these for a ~300ms improvement in startup time
|
||||
let airplayer, chromecasts, dlnacasts
|
||||
|
||||
// App state. Cast modifies state.playing and state.errors in response to events
|
||||
let state
|
||||
|
||||
// Callback to notify module users when state has changed
|
||||
let update
|
||||
|
||||
// setInterval() for updating cast status
|
||||
let statusInterval = null
|
||||
|
||||
// Start looking for cast devices on the local network
|
||||
function init (appState, callback) {
|
||||
state = appState
|
||||
update = callback
|
||||
|
||||
// Don't actually cast during integration tests
|
||||
// (Otherwise you'd need a physical Chromecast + AppleTV + DLNA TV to run them.)
|
||||
if (config.IS_TEST) {
|
||||
state.devices.chromecast = testPlayer('chromecast')
|
||||
state.devices.airplay = testPlayer('airplay')
|
||||
state.devices.dlna = testPlayer('dlna')
|
||||
return
|
||||
}
|
||||
|
||||
// Load modules, scan the network for devices
|
||||
airplayer = require('airplayer')()
|
||||
chromecasts = require('chromecasts')()
|
||||
dlnacasts = require('dlnacasts')()
|
||||
|
||||
state.devices.chromecast = chromecastPlayer()
|
||||
state.devices.dlna = dlnaPlayer()
|
||||
state.devices.airplay = airplayPlayer()
|
||||
|
||||
// Listen for devices: Chromecast, DLNA and Airplay
|
||||
chromecasts.on('update', device => {
|
||||
// TODO: how do we tell if there are *no longer* any Chromecasts available?
|
||||
// From looking at the code, chromecasts.players only grows, never shrinks
|
||||
state.devices.chromecast.addDevice(device)
|
||||
})
|
||||
|
||||
dlnacasts.on('update', device => {
|
||||
state.devices.dlna.addDevice(device)
|
||||
})
|
||||
|
||||
airplayer.on('update', 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
|
||||
function chromecastPlayer () {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
return ret
|
||||
|
||||
function getDevices () {
|
||||
return chromecasts.players
|
||||
}
|
||||
|
||||
function addDevice (device) {
|
||||
device.on('error', err => {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to Chromecast. ' + err.message
|
||||
})
|
||||
update()
|
||||
})
|
||||
device.on('disconnect', () => {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
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.slice(21), 'base64'))
|
||||
}).listen(0, () => {
|
||||
const port = ret.subServer.address().port
|
||||
const subtitlesUrl = 'http://' + state.server.networkAddress + ':' + port + '/'
|
||||
callback(subtitlesUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function open () {
|
||||
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
|
||||
serveSubtitles(subtitlesUrl => {
|
||||
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
|
||||
type: 'video/mp4',
|
||||
title: config.APP_NAME + ' - ' + torrentSummary.name,
|
||||
subtitles: subtitlesUrl ? [subtitlesUrl] : [],
|
||||
autoSubtitles: !!subtitlesUrl
|
||||
}, err => {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to Chromecast. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'chromecast'
|
||||
}
|
||||
update()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.play(null, null, callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
if (ret.subServer) {
|
||||
ret.subServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.status(handleStatus)
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.seek(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
ret.device.volume(volume, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// airplay player implementation
|
||||
function airplayPlayer () {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
return ret
|
||||
|
||||
function addDevice (player) {
|
||||
player.on('event', event => {
|
||||
switch (event.state) {
|
||||
case 'loading':
|
||||
break
|
||||
case 'playing':
|
||||
state.playing.isPaused = false
|
||||
break
|
||||
case 'paused':
|
||||
state.playing.isPaused = true
|
||||
break
|
||||
case 'stopped':
|
||||
break
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function getDevices () {
|
||||
return airplayer.players
|
||||
}
|
||||
|
||||
function open () {
|
||||
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, (err, res) => {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to AirPlay. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'airplay'
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.resume(callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.playbackInfo((err, res, status) => {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to AirPlay. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.isPaused = status.rate === 0
|
||||
state.playing.currentTime = status.position
|
||||
update()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.scrub(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
// AirPlay doesn't support volume
|
||||
// TODO: We should just disable the volume slider
|
||||
state.playing.volume = volume
|
||||
}
|
||||
}
|
||||
|
||||
// DLNA player implementation
|
||||
function dlnaPlayer (player) {
|
||||
const ret = {
|
||||
device: null,
|
||||
addDevice,
|
||||
getDevices,
|
||||
open,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
status,
|
||||
seek,
|
||||
volume
|
||||
}
|
||||
return ret
|
||||
|
||||
function getDevices () {
|
||||
return dlnacasts.players
|
||||
}
|
||||
|
||||
function addDevice (device) {
|
||||
device.on('error', err => {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to DLNA. ' + err.message
|
||||
})
|
||||
update()
|
||||
})
|
||||
device.on('disconnect', () => {
|
||||
if (device !== ret.device) return
|
||||
state.playing.location = 'local'
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function open () {
|
||||
const torrentSummary = state.saved.torrents.find((x) => x.infoHash === state.playing.infoHash)
|
||||
ret.device.play(state.server.networkURL + '/' + state.playing.fileIndex, {
|
||||
type: 'video/mp4',
|
||||
title: config.APP_NAME + ' - ' + torrentSummary.name,
|
||||
seek: state.playing.currentTime > 10 ? state.playing.currentTime : 0
|
||||
}, err => {
|
||||
if (err) {
|
||||
state.playing.location = 'local'
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: 'Could not connect to DLNA. ' + err.message
|
||||
})
|
||||
} else {
|
||||
state.playing.location = 'dlna'
|
||||
}
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
function play (callback) {
|
||||
ret.device.play(null, null, callback)
|
||||
}
|
||||
|
||||
function pause (callback) {
|
||||
ret.device.pause(callback)
|
||||
}
|
||||
|
||||
function stop (callback) {
|
||||
ret.device.stop(callback)
|
||||
}
|
||||
|
||||
function status () {
|
||||
ret.device.status(handleStatus)
|
||||
}
|
||||
|
||||
function seek (time, callback) {
|
||||
ret.device.seek(time, callback)
|
||||
}
|
||||
|
||||
function volume (volume, callback) {
|
||||
ret.device.volume(volume, err => {
|
||||
// quick volume update
|
||||
state.playing.volume = volume
|
||||
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
|
||||
function startStatusInterval () {
|
||||
statusInterval = setInterval(() => {
|
||||
const player = getPlayer()
|
||||
if (player) player.status()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* If the menu was already showing for that type, hides the menu.
|
||||
*/
|
||||
function toggleMenu (location) {
|
||||
// If the menu is already showing, hide it
|
||||
if (state.devices.castMenu && state.devices.castMenu.location === location) {
|
||||
state.devices.castMenu = null
|
||||
return
|
||||
}
|
||||
|
||||
// Never cast to two devices at the same time
|
||||
if (state.playing.location !== 'local') {
|
||||
throw new CastingError(
|
||||
`You can't connect to ${location} when already connected to another device`
|
||||
)
|
||||
}
|
||||
|
||||
// Find all cast devices of the given type
|
||||
const player = getPlayer(location)
|
||||
const devices = player ? player.getDevices() : []
|
||||
if (devices.length === 0) {
|
||||
throw new CastingError(`No ${location} devices available`)
|
||||
}
|
||||
|
||||
// Show a menu
|
||||
state.devices.castMenu = { location, devices }
|
||||
}
|
||||
|
||||
function selectDevice (index) {
|
||||
const { location, devices } = state.devices.castMenu
|
||||
|
||||
// Start casting
|
||||
const player = getPlayer(location)
|
||||
player.device = devices[index]
|
||||
player.open()
|
||||
|
||||
// Poll the casting device's status every few seconds
|
||||
startStatusInterval()
|
||||
|
||||
// Show the Connecting... screen
|
||||
state.devices.castMenu = null
|
||||
state.playing.castName = devices[index].name
|
||||
state.playing.location = location + '-pending'
|
||||
update()
|
||||
}
|
||||
|
||||
// Stops casting, move video back to local screen
|
||||
function stop () {
|
||||
const player = getPlayer()
|
||||
if (player) {
|
||||
player.stop(() => {
|
||||
player.device = null
|
||||
stoppedCasting()
|
||||
})
|
||||
clearInterval(statusInterval)
|
||||
} else {
|
||||
stoppedCasting()
|
||||
}
|
||||
}
|
||||
|
||||
function stoppedCasting () {
|
||||
state.playing.location = 'local'
|
||||
state.playing.jumpToTime = Number.isFinite(state.playing.currentTime)
|
||||
? state.playing.currentTime
|
||||
: 0
|
||||
update()
|
||||
}
|
||||
|
||||
function getPlayer (location) {
|
||||
if (location) {
|
||||
return state.devices[location]
|
||||
} else if (state.playing.location === 'chromecast') {
|
||||
return state.devices.chromecast
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
return state.devices.airplay
|
||||
} else if (state.playing.location === 'dlna') {
|
||||
return state.devices.dlna
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function play () {
|
||||
const player = getPlayer()
|
||||
if (player) player.play(castCallback)
|
||||
}
|
||||
|
||||
function pause () {
|
||||
const player = getPlayer()
|
||||
if (player) player.pause(castCallback)
|
||||
}
|
||||
|
||||
function setRate (rate) {
|
||||
let player
|
||||
let result = true
|
||||
if (state.playing.location === 'chromecast') {
|
||||
// TODO find how to control playback rate on chromecast
|
||||
castCallback()
|
||||
result = false
|
||||
} else if (state.playing.location === 'airplay') {
|
||||
player = state.devices.airplay
|
||||
player.rate(rate, castCallback)
|
||||
} else {
|
||||
result = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function seek (time) {
|
||||
const player = getPlayer()
|
||||
if (player) player.seek(time, castCallback)
|
||||
}
|
||||
|
||||
function setVolume (volume) {
|
||||
const player = getPlayer()
|
||||
if (player) player.volume(volume, castCallback)
|
||||
}
|
||||
|
||||
function castCallback (...args) {
|
||||
console.log('%s callback: %o', state.playing.location, args)
|
||||
}
|
39
src/renderer/lib/dispatcher.js
Normal file
@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
dispatch,
|
||||
dispatcher,
|
||||
setDispatch
|
||||
}
|
||||
|
||||
const dispatchers = {}
|
||||
let _dispatch = () => {}
|
||||
|
||||
function setDispatch (dispatch) {
|
||||
_dispatch = dispatch
|
||||
}
|
||||
|
||||
function dispatch (...args) {
|
||||
_dispatch(...args)
|
||||
}
|
||||
|
||||
// Most DOM event handlers are trivial functions like `() => dispatch(<args>)`.
|
||||
// For these, `dispatcher(<args>)` is preferred because it memoizes the handler
|
||||
// function. This prevents React from updating the listener functions on
|
||||
// each update().
|
||||
function dispatcher (...args) {
|
||||
const str = JSON.stringify(args)
|
||||
let handler = dispatchers[str]
|
||||
if (!handler) {
|
||||
handler = dispatchers[str] = e => {
|
||||
// Do not propagate click to elements below the button
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.currentTarget.classList.contains('disabled')) {
|
||||
// Ignore clicks on disabled elements
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(...args)
|
||||
}
|
||||
}
|
||||
return handler
|
||||
}
|
41
src/renderer/lib/errors.js
Normal file
@ -0,0 +1,41 @@
|
||||
const ExtendableError = require('es6-error')
|
||||
|
||||
/* Generic errors */
|
||||
|
||||
class CastingError extends ExtendableError {}
|
||||
class PlaybackError extends ExtendableError {}
|
||||
class SoundError extends ExtendableError {}
|
||||
class TorrentError extends ExtendableError {}
|
||||
|
||||
/* Playback */
|
||||
|
||||
class UnplayableTorrentError extends PlaybackError {
|
||||
constructor () { super('Can\'t play any files in torrent') }
|
||||
}
|
||||
|
||||
class UnplayableFileError extends PlaybackError {
|
||||
constructor () { super('Can\'t play that file') }
|
||||
}
|
||||
|
||||
/* Sound */
|
||||
|
||||
class InvalidSoundNameError extends SoundError {
|
||||
constructor (name) { super(`Invalid sound name: ${name}`) }
|
||||
}
|
||||
|
||||
/* Torrent */
|
||||
|
||||
class TorrentKeyNotFoundError extends TorrentError {
|
||||
constructor (torrentKey) { super(`Can't resolve torrent key ${torrentKey}`) }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CastingError,
|
||||
PlaybackError,
|
||||
SoundError,
|
||||
TorrentError,
|
||||
UnplayableTorrentError,
|
||||
UnplayableFileError,
|
||||
InvalidSoundNameError,
|
||||
TorrentKeyNotFoundError
|
||||
}
|
12
src/renderer/lib/media-extensions.js
Normal file
@ -0,0 +1,12 @@
|
||||
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']
|
||||
}
|
||||
|
||||
module.exports = mediaExtensions
|
234
src/renderer/lib/migrations.js
Normal file
@ -0,0 +1,234 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
module.exports = {
|
||||
run
|
||||
}
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const semver = require('semver')
|
||||
|
||||
const config = require('../../config')
|
||||
|
||||
// Change `state.saved` (which will be saved back to config.json on exit) as
|
||||
// needed, for example to deal with config.json format changes across versions
|
||||
function run (state) {
|
||||
// Replace '{ version: 1 }' with app version (semver)
|
||||
if (!semver.valid(state.saved.version)) {
|
||||
state.saved.version = '0.0.0' // Pre-0.7.0 version, so run all migrations
|
||||
}
|
||||
|
||||
const version = state.saved.version
|
||||
const saved = state.saved
|
||||
|
||||
if (semver.lt(version, '0.7.0')) migrate_0_7_0(saved)
|
||||
if (semver.lt(version, '0.7.2')) migrate_0_7_2(saved)
|
||||
if (semver.lt(version, '0.11.0')) migrate_0_11_0(saved)
|
||||
if (semver.lt(version, '0.12.0')) migrate_0_12_0(saved)
|
||||
if (semver.lt(version, '0.14.0')) migrate_0_14_0(saved)
|
||||
if (semver.lt(version, '0.17.0')) migrate_0_17_0(saved)
|
||||
if (semver.lt(version, '0.17.2')) migrate_0_17_2(saved)
|
||||
if (semver.lt(version, '0.21.0')) migrate_0_21_0(saved)
|
||||
if (semver.lt(version, '0.22.0')) migrate_0_22_0(saved)
|
||||
|
||||
if (semver.lt(version, config.APP_VERSION)) {
|
||||
installHandlers(state.saved)
|
||||
}
|
||||
|
||||
// Config is now on the new version
|
||||
state.saved.version = config.APP_VERSION
|
||||
}
|
||||
|
||||
// Whenever the app is updated, re-install default handlers if the user has
|
||||
// enabled them.
|
||||
function installHandlers (saved) {
|
||||
if (saved.prefs.isFileHandler) {
|
||||
const ipcRenderer = require('electron').ipcRenderer
|
||||
ipcRenderer.send('setDefaultFileHandler', true)
|
||||
}
|
||||
}
|
||||
|
||||
function migrate_0_7_0 (saved) {
|
||||
const { copyFileSync } = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
saved.torrents.forEach(ts => {
|
||||
const infoHash = ts.infoHash
|
||||
|
||||
// Replace torrentPath with torrentFileName
|
||||
// There are a number of cases to handle here:
|
||||
// * Originally we used absolute paths
|
||||
// * Then, relative paths for the default torrents, eg '../static/sintel.torrent'
|
||||
// * Then, paths computed at runtime for default torrents, eg 'sintel.torrent'
|
||||
// * Finally, now we're getting rid of torrentPath altogether
|
||||
let src, dst
|
||||
if (ts.torrentPath) {
|
||||
if (path.isAbsolute(ts.torrentPath) || ts.torrentPath.startsWith('..')) {
|
||||
src = ts.torrentPath
|
||||
} else {
|
||||
src = path.join(config.STATIC_PATH, ts.torrentPath)
|
||||
}
|
||||
dst = path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
// Synchronous FS calls aren't ideal, but probably OK in a migration
|
||||
// that only runs once
|
||||
if (src !== dst) copyFileSync(src, dst)
|
||||
|
||||
delete ts.torrentPath
|
||||
ts.torrentFileName = infoHash + '.torrent'
|
||||
}
|
||||
|
||||
// Replace posterURL with posterFileName
|
||||
if (ts.posterURL) {
|
||||
const extension = path.extname(ts.posterURL)
|
||||
src = path.isAbsolute(ts.posterURL)
|
||||
? ts.posterURL
|
||||
: path.join(config.STATIC_PATH, ts.posterURL)
|
||||
dst = path.join(config.POSTER_PATH, infoHash + extension)
|
||||
// Synchronous FS calls aren't ideal, but probably OK in a migration
|
||||
// that only runs once
|
||||
if (src !== dst) copyFileSync(src, dst)
|
||||
|
||||
delete ts.posterURL
|
||||
ts.posterFileName = infoHash + extension
|
||||
}
|
||||
|
||||
// Fix exception caused by incorrect file ordering.
|
||||
// https://github.com/leenkx/leenkxbox/pull/604#issuecomment-222805214
|
||||
delete ts.defaultPlayFileIndex
|
||||
delete ts.files
|
||||
delete ts.selections
|
||||
delete ts.fileModtimes
|
||||
})
|
||||
}
|
||||
|
||||
function migrate_0_7_2 (saved) {
|
||||
if (saved.prefs == null) {
|
||||
saved.prefs = {
|
||||
downloadPath: config.DEFAULT_DOWNLOAD_PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrate_0_11_0 (saved) {
|
||||
if (saved.prefs.isFileHandler == null) {
|
||||
// The app used to make itself the default torrent file handler automatically
|
||||
saved.prefs.isFileHandler = true
|
||||
}
|
||||
}
|
||||
|
||||
function migrate_0_12_0 (saved) {
|
||||
const TorrentSummary = require('./torrent-summary')
|
||||
|
||||
if (saved.prefs.openExternalPlayer == null && saved.prefs.playInVlc != null) {
|
||||
saved.prefs.openExternalPlayer = saved.prefs.playInVlc
|
||||
}
|
||||
delete saved.prefs.playInVlc
|
||||
|
||||
// Undo a terrible bug where clicking Play on a default torrent on a fresh
|
||||
// install results in a "path missing" error
|
||||
// See https://github.com/leenkx/leenkxbox/pull/806
|
||||
const defaultTorrentFiles = [
|
||||
'6a9759bffd5c0af65319979fb7832189f4f3c35d.torrent',
|
||||
'88594aaacbde40ef3e2510c47374ec0aa396c08e.torrent',
|
||||
'6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5.torrent',
|
||||
'02767050e0be2fd4db9a2ad6c12416ac806ed6ed.torrent',
|
||||
'3ba219a8634bf7bae3d848192b2da75ae995589d.torrent'
|
||||
]
|
||||
saved.torrents.forEach(torrentSummary => {
|
||||
if (!defaultTorrentFiles.includes(torrentSummary.torrentFileName)) return
|
||||
const fileOrFolder = TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
if (!fileOrFolder) return
|
||||
try {
|
||||
fs.statSync(fileOrFolder)
|
||||
} catch (err) {
|
||||
// Default torrent with "missing path" error. Clear path.
|
||||
delete torrentSummary.path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function migrate_0_14_0 (saved) {
|
||||
saved.torrents.forEach(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/leenkx/leenkxbox/issues/905
|
||||
|
||||
const { copyFileSync } = require('fs')
|
||||
const rimraf = require('rimraf')
|
||||
|
||||
const OLD_NAME = 'The WIRED CD - Rip. Sample. Mash. Share.'
|
||||
const NEW_NAME = 'The WIRED CD - Rip. Sample. Mash. Share'
|
||||
|
||||
const OLD_HASH = '3ba219a8634bf7bae3d848192b2da75ae995589d'
|
||||
const NEW_HASH = 'a88fda5954e89178c372716a6a78b8180ed4dad3'
|
||||
|
||||
const ts = saved.torrents.find(ts => ts.infoHash === OLD_HASH)
|
||||
|
||||
if (!ts) return // Wired CD torrent does not exist
|
||||
|
||||
// New versions of WebTorrent ship with a fixed torrent file. Let's fix up the
|
||||
// name in existing versions of WebTorrent.
|
||||
ts.name = ts.displayName = NEW_NAME
|
||||
ts.files.forEach((file) => {
|
||||
file.path = file.path.replace(OLD_NAME, NEW_NAME)
|
||||
})
|
||||
|
||||
// Changing the torrent name causes the info hash to change
|
||||
ts.infoHash = NEW_HASH
|
||||
ts.magnetURI = ts.magnetURI.replace(OLD_HASH, NEW_HASH)
|
||||
|
||||
try {
|
||||
fs.renameSync(
|
||||
path.join(config.POSTER_PATH, ts.posterFileName),
|
||||
path.join(config.POSTER_PATH, NEW_HASH + '.jpg')
|
||||
)
|
||||
} catch (err) {}
|
||||
ts.posterFileName = NEW_HASH + '.jpg'
|
||||
|
||||
rimraf.sync(path.join(config.TORRENT_PATH, ts.torrentFileName))
|
||||
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 = ''
|
||||
}
|
||||
}
|
88
src/renderer/lib/playlist.js
Normal file
@ -0,0 +1,88 @@
|
||||
module.exports = {
|
||||
hasNext,
|
||||
getNextIndex,
|
||||
hasPrevious,
|
||||
getPreviousIndex,
|
||||
getCurrentLocalURL
|
||||
}
|
||||
|
||||
const TorrentSummary = require('./torrent-summary')
|
||||
const TorrentPlayer = require('./torrent-player')
|
||||
|
||||
const cache = {
|
||||
infoHash: null,
|
||||
previousIndex: null,
|
||||
currentIndex: null,
|
||||
nextIndex: null
|
||||
}
|
||||
|
||||
function hasNext (state) {
|
||||
updateCache(state)
|
||||
return cache.nextIndex !== null
|
||||
}
|
||||
|
||||
function getNextIndex (state) {
|
||||
updateCache(state)
|
||||
return cache.nextIndex
|
||||
}
|
||||
|
||||
function hasPrevious (state) {
|
||||
updateCache(state)
|
||||
return cache.previousIndex !== null
|
||||
}
|
||||
|
||||
function getPreviousIndex (state) {
|
||||
updateCache(state)
|
||||
return cache.previousIndex
|
||||
}
|
||||
|
||||
function getCurrentLocalURL (state) {
|
||||
return state.server
|
||||
? state.server.localURL + '/' + state.playing.fileIndex + '/' +
|
||||
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
|
||||
}
|
78
src/renderer/lib/sound.js
Normal file
@ -0,0 +1,78 @@
|
||||
module.exports = {
|
||||
init,
|
||||
play
|
||||
}
|
||||
|
||||
const config = require('../../config')
|
||||
const { InvalidSoundNameError } = require('./errors')
|
||||
const path = require('path')
|
||||
|
||||
const VOLUME = 0.25
|
||||
|
||||
// App state to access the soundNotifications preference
|
||||
let state
|
||||
|
||||
/* Cache of Audio elements, for instant playback */
|
||||
const cache = {}
|
||||
|
||||
const sounds = {
|
||||
ADD: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'add.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
DELETE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'delete.wav'),
|
||||
volume: VOLUME * 0.5
|
||||
},
|
||||
DISABLE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'disable.wav'),
|
||||
volume: VOLUME * 0.5
|
||||
},
|
||||
DONE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'done.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
ENABLE: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'enable.wav'),
|
||||
volume: VOLUME * 0.5
|
||||
},
|
||||
ERROR: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'error.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
PLAY: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'play.wav'),
|
||||
volume: VOLUME
|
||||
},
|
||||
STARTUP: {
|
||||
url: 'file://' + path.join(config.STATIC_PATH, 'sound', 'startup.wav'),
|
||||
volume: VOLUME
|
||||
}
|
||||
}
|
||||
|
||||
function init (appState) {
|
||||
state = appState
|
||||
}
|
||||
|
||||
function play (name) {
|
||||
if (state == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.saved.prefs.soundNotifications) {
|
||||
return
|
||||
}
|
||||
|
||||
let audio = cache[name]
|
||||
if (!audio) {
|
||||
const sound = sounds[name]
|
||||
if (!sound) {
|
||||
throw new InvalidSoundNameError(name)
|
||||
}
|
||||
audio = cache[name] = new window.Audio()
|
||||
audio.volume = sound.volume
|
||||
audio.src = sound.url
|
||||
}
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
}
|
275
src/renderer/lib/state.js
Normal file
@ -0,0 +1,275 @@
|
||||
const appConfig = require('application-config')('WebTorrent')
|
||||
const path = require('path')
|
||||
const { EventEmitter } = require('events')
|
||||
|
||||
const config = require('../../config')
|
||||
const defaultAnnounceList = require('create-torrent').announceList.map((arr) => arr[0])
|
||||
|
||||
const SAVE_DEBOUNCE_INTERVAL = 1000
|
||||
|
||||
appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
|
||||
|
||||
const State = module.exports = Object.assign(new EventEmitter(), {
|
||||
getDefaultPlayState,
|
||||
load,
|
||||
// state.save() calls are rate-limited. Use state.saveImmediate() to skip limit.
|
||||
save (...args) {
|
||||
// Perf optimization: Lazy-require debounce (and it's dependencies)
|
||||
const debounce = require('debounce')
|
||||
// After first State.save() invokation, future calls go straight to the
|
||||
// debounced function
|
||||
State.save = debounce(saveImmediate, SAVE_DEBOUNCE_INTERVAL)
|
||||
State.save(...args)
|
||||
},
|
||||
saveImmediate
|
||||
})
|
||||
|
||||
function getDefaultState () {
|
||||
const LocationHistory = require('location-history')
|
||||
|
||||
return {
|
||||
/*
|
||||
* Temporary state disappears once the program exits.
|
||||
* It can contain complex objects like open connections, etc.
|
||||
*/
|
||||
client: null, /* the WebTorrent client */
|
||||
server: null, /* local WebTorrent-to-HTTP server */
|
||||
prev: { /* used for state diffing in updateElectron() */
|
||||
title: null,
|
||||
progress: -1,
|
||||
badge: null
|
||||
},
|
||||
location: new LocationHistory(),
|
||||
window: {
|
||||
bounds: null, /* {x, y, width, height } */
|
||||
isFocused: true,
|
||||
isFullScreen: false,
|
||||
title: config.APP_WINDOW_TITLE
|
||||
},
|
||||
selectedInfoHash: null, /* the torrent we've selected to view details. see state.torrents */
|
||||
playing: getDefaultPlayState(), /* the media (audio or video) that we're currently playing */
|
||||
devices: {}, /* playback devices like Chromecast and AppleTV */
|
||||
dock: {
|
||||
badge: 0,
|
||||
progress: 0
|
||||
},
|
||||
modal: null, /* modal popover */
|
||||
errors: [], /* user-facing errors */
|
||||
nextTorrentKey: 1, /* identify torrents for IPC between the main and webtorrent windows */
|
||||
|
||||
/*
|
||||
* Saved state is read from and written to a file every time the app runs.
|
||||
* It should be simple and minimal and must be JSON.
|
||||
* It must never contain absolute paths since we have a portable app.
|
||||
*
|
||||
* Config path:
|
||||
*
|
||||
* Mac ~/Library/Application Support/WebTorrent/config.json
|
||||
* Linux (XDG) $XDG_CONFIG_HOME/WebTorrent/config.json
|
||||
* Linux (Legacy) ~/.config/WebTorrent/config.json
|
||||
* Windows (> Vista) %LOCALAPPDATA%/WebTorrent/config.json
|
||||
* Windows (XP, 2000) %USERPROFILE%/Local Settings/Application Data/WebTorrent/config.json
|
||||
*
|
||||
* Also accessible via `require('application-config')('WebTorrent').filePath`
|
||||
*/
|
||||
saved: {},
|
||||
|
||||
/*
|
||||
* Getters, for convenience
|
||||
*/
|
||||
getPlayingTorrentSummary,
|
||||
getPlayingFileSummary,
|
||||
getExternalPlayerName,
|
||||
getGlobalTrackers,
|
||||
shouldHidePlayerControls
|
||||
}
|
||||
}
|
||||
|
||||
/* Whenever we stop playing video or audio, here's what we reset state.playing to */
|
||||
function getDefaultPlayState () {
|
||||
return {
|
||||
infoHash: null, /* the info hash of the torrent we're playing */
|
||||
fileIndex: null, /* the zero-based index within the torrent */
|
||||
fileName: null, /* name of the file that is playing */
|
||||
location: 'local', /* 'local', 'chromecast', 'airplay' */
|
||||
type: null, /* 'audio' or 'video', could be 'other' if ever support eg streaming to VLC */
|
||||
currentTime: 0, /* seconds */
|
||||
duration: 1, /* seconds */
|
||||
isReady: false,
|
||||
isPaused: true,
|
||||
isStalled: false,
|
||||
lastTimeUpdate: 0, /* Unix time in ms */
|
||||
mouseStationarySince: 0, /* Unix time in ms */
|
||||
playbackRate: 1,
|
||||
volume: 1,
|
||||
subtitles: {
|
||||
tracks: [], /* subtitle tracks, each {label, language, ...} */
|
||||
selectedIndex: -1, /* current subtitle track */
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
/* If the saved state file doesn't exist yet, here's what we use instead */
|
||||
function setupStateSaved () {
|
||||
const { copyFileSync, mkdirSync, readFileSync } = require('fs')
|
||||
const parseTorrent = require('parse-torrent')
|
||||
|
||||
const saved = {
|
||||
prefs: {
|
||||
downloadPath: config.DEFAULT_DOWNLOAD_PATH,
|
||||
isFileHandler: false,
|
||||
openExternalPlayer: false,
|
||||
externalPlayerPath: '',
|
||||
startup: false,
|
||||
soundNotifications: true,
|
||||
autoAddTorrents: false,
|
||||
torrentsFolderPath: '',
|
||||
highestPlaybackPriority: true,
|
||||
globalTrackers: defaultAnnounceList
|
||||
},
|
||||
torrents: config.DEFAULT_TORRENTS.map(createTorrentObject),
|
||||
torrentsToResume: [],
|
||||
version: config.APP_VERSION /* make sure we can upgrade gracefully later */
|
||||
}
|
||||
|
||||
// TODO: Doing several sync calls during first startup is not ideal
|
||||
mkdirSync(config.POSTER_PATH, { recursive: true })
|
||||
mkdirSync(config.TORRENT_PATH, { recursive: true })
|
||||
|
||||
config.DEFAULT_TORRENTS.forEach((t, i) => {
|
||||
const infoHash = saved.torrents[i].infoHash
|
||||
// TODO: Doing several sync calls during first startup is not ideal
|
||||
copyFileSync(
|
||||
path.join(config.STATIC_PATH, t.posterFileName),
|
||||
path.join(config.POSTER_PATH, infoHash + path.extname(t.posterFileName))
|
||||
)
|
||||
copyFileSync(
|
||||
path.join(config.STATIC_PATH, t.torrentFileName),
|
||||
path.join(config.TORRENT_PATH, infoHash + '.torrent')
|
||||
)
|
||||
})
|
||||
|
||||
return saved
|
||||
|
||||
function createTorrentObject (t) {
|
||||
// TODO: Doing several sync calls during first startup is not ideal
|
||||
const torrent = readFileSync(path.join(config.STATIC_PATH, t.torrentFileName))
|
||||
const parsedTorrent = parseTorrent(torrent)
|
||||
|
||||
return {
|
||||
status: 'paused',
|
||||
infoHash: parsedTorrent.infoHash,
|
||||
name: t.name,
|
||||
displayName: t.name,
|
||||
posterFileName: parsedTorrent.infoHash + path.extname(t.posterFileName),
|
||||
torrentFileName: parsedTorrent.infoHash + '.torrent',
|
||||
magnetURI: parseTorrent.toMagnetURI(parsedTorrent),
|
||||
files: parsedTorrent.files,
|
||||
selections: parsedTorrent.files.map((x) => true),
|
||||
testID: t.testID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayingTorrentSummary () {
|
||||
const infoHash = this.playing.infoHash
|
||||
return this.saved.torrents.find((x) => x.infoHash === infoHash)
|
||||
}
|
||||
|
||||
function getPlayingFileSummary () {
|
||||
const torrentSummary = this.getPlayingTorrentSummary()
|
||||
if (!torrentSummary) return null
|
||||
return torrentSummary.files[this.playing.fileIndex]
|
||||
}
|
||||
|
||||
function getExternalPlayerName () {
|
||||
const playerPath = this.saved.prefs.externalPlayerPath
|
||||
if (!playerPath) return 'VLC'
|
||||
return path.basename(playerPath).split('.')[0]
|
||||
}
|
||||
|
||||
function shouldHidePlayerControls () {
|
||||
return this.location.url() === 'player' &&
|
||||
this.playing.mouseStationarySince !== 0 &&
|
||||
new Date().getTime() - this.playing.mouseStationarySince > 2000 &&
|
||||
!this.playing.mouseInControls &&
|
||||
!this.playing.isPaused &&
|
||||
this.playing.location === 'local'
|
||||
}
|
||||
|
||||
function getGlobalTrackers () {
|
||||
const trackers = this.saved.prefs.globalTrackers
|
||||
if (!trackers) {
|
||||
return defaultAnnounceList
|
||||
}
|
||||
return trackers
|
||||
}
|
||||
|
||||
async function load (cb) {
|
||||
let saved = await appConfig.read()
|
||||
|
||||
if (!saved || !saved.version) {
|
||||
console.log('Missing config file: Creating new one')
|
||||
try {
|
||||
saved = setupStateSaved()
|
||||
} catch (err) {
|
||||
onSavedState(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onSavedState(null, saved)
|
||||
|
||||
function onSavedState (err, saved) {
|
||||
if (err) return cb(err)
|
||||
const state = getDefaultState()
|
||||
state.saved = saved
|
||||
|
||||
if (process.type === 'renderer') {
|
||||
// Perf optimization: Save require() calls in the main process
|
||||
const migrations = require('./migrations')
|
||||
migrations.run(state)
|
||||
}
|
||||
|
||||
cb(null, state)
|
||||
}
|
||||
}
|
||||
|
||||
// Write state.saved to the JSON state file
|
||||
async function saveImmediate (state, cb) {
|
||||
console.log('Saving state to ' + appConfig.filePath)
|
||||
|
||||
// Clean up, so that we're not saving any pending state
|
||||
const copy = Object.assign({}, state.saved)
|
||||
// Remove torrents pending addition to the list, where we haven't finished
|
||||
// reading the torrent file or file(s) to seed & don't have an infohash
|
||||
copy.torrents = copy.torrents
|
||||
.filter((x) => x.infoHash)
|
||||
.map(x => {
|
||||
const torrent = {}
|
||||
for (const key in x) {
|
||||
if (key === 'progress' || key === 'torrentKey') {
|
||||
continue // Don't save progress info or key for the webtorrent process
|
||||
}
|
||||
if (key === 'error') {
|
||||
continue // Don't save error states
|
||||
}
|
||||
torrent[key] = x[key]
|
||||
}
|
||||
return torrent
|
||||
})
|
||||
|
||||
try {
|
||||
await appConfig.write(copy)
|
||||
State.emit('stateSaved')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
222
src/renderer/lib/telemetry.js
Normal file
@ -0,0 +1,222 @@
|
||||
// Collects anonymous usage stats and uncaught errors
|
||||
// Reports back so that we can improve WebTorrent Desktop
|
||||
module.exports = {
|
||||
init,
|
||||
send,
|
||||
logUncaughtError,
|
||||
logPlayAttempt
|
||||
}
|
||||
|
||||
const remote = require('@electron/remote')
|
||||
|
||||
const config = require('../../config')
|
||||
|
||||
let telemetry
|
||||
|
||||
function init (state) {
|
||||
telemetry = state.saved.telemetry
|
||||
|
||||
// First app run
|
||||
if (!telemetry) {
|
||||
const crypto = require('crypto')
|
||||
telemetry = state.saved.telemetry = {
|
||||
userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID
|
||||
}
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
function send (state) {
|
||||
const now = new Date()
|
||||
telemetry.version = config.APP_VERSION
|
||||
telemetry.timestamp = now.toISOString()
|
||||
telemetry.localTime = now.toTimeString()
|
||||
telemetry.screens = getScreenInfo()
|
||||
telemetry.system = getSystemInfo()
|
||||
telemetry.torrentStats = getTorrentStats(state)
|
||||
telemetry.approxNumTorrents = telemetry.torrentStats.approxCount
|
||||
|
||||
if (!config.IS_PRODUCTION) {
|
||||
// Development: telemetry used only for local debugging
|
||||
// Empty uncaught errors, etc at the start of every run
|
||||
return reset()
|
||||
}
|
||||
|
||||
const get = require('simple-get')
|
||||
|
||||
const opts = {
|
||||
url: config.TELEMETRY_URL,
|
||||
body: telemetry,
|
||||
json: true
|
||||
}
|
||||
|
||||
get.post(opts, (err, res) => {
|
||||
if (err) return console.error('Error sending telemetry', err)
|
||||
if (res.statusCode !== 200) {
|
||||
return console.error(`Error sending telemetry, status code: ${res.statusCode}`)
|
||||
}
|
||||
console.log('Sent telemetry')
|
||||
reset()
|
||||
})
|
||||
}
|
||||
|
||||
function reset () {
|
||||
telemetry.uncaughtErrors = []
|
||||
telemetry.playAttempts = {
|
||||
minVersion: config.APP_VERSION,
|
||||
total: 0,
|
||||
success: 0,
|
||||
error: 0,
|
||||
external: 0,
|
||||
abandoned: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Track screen resolution
|
||||
function getScreenInfo () {
|
||||
return remote.screen.getAllDisplays().map((screen) => ({
|
||||
width: screen.size.width,
|
||||
height: screen.size.height,
|
||||
scaleFactor: screen.scaleFactor
|
||||
}))
|
||||
}
|
||||
|
||||
// Track basic system info like OS version and amount of RAM
|
||||
function getSystemInfo () {
|
||||
const os = require('os')
|
||||
return {
|
||||
osPlatform: process.platform,
|
||||
osRelease: os.type() + ' ' + os.release(),
|
||||
architecture: os.arch(),
|
||||
systemArchitecture: config.OS_SYSARCH,
|
||||
totalMemoryMB: roundPow2(os.totalmem() / (1 << 20)),
|
||||
numCores: os.cpus().length
|
||||
}
|
||||
}
|
||||
|
||||
// Get stats like the # of torrents currently active, # in list, total size
|
||||
function getTorrentStats (state) {
|
||||
const count = state.saved.torrents.length
|
||||
let sizeMB = 0
|
||||
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
|
||||
const log2 = Math.log(n) / Math.log(2)
|
||||
return 2 ** Math.round(log2)
|
||||
}
|
||||
|
||||
// An uncaught error happened in the main process or in one of the windows
|
||||
function logUncaughtError (procName, e) {
|
||||
// Not initialized yet? Ignore.
|
||||
// Hopefully uncaught errors immediately on startup are fixed in dev
|
||||
if (!telemetry) return
|
||||
|
||||
let message
|
||||
let stack = ''
|
||||
if (e == null) {
|
||||
message = 'Unexpected undefined error'
|
||||
} else if (e.error) {
|
||||
// Uncaught Javascript errors (window.onerror), err is an ErrorEvent
|
||||
if (!e.error.message) {
|
||||
message = 'Unexpected ErrorEvent.error: ' + Object.keys(e.error).join(' ')
|
||||
} else {
|
||||
message = e.error.message
|
||||
stack = e.error.stack
|
||||
}
|
||||
} else if (e.message) {
|
||||
// err is either an Error or a plain object {message, stack}
|
||||
message = e.message
|
||||
stack = e.stack
|
||||
} else {
|
||||
// Resource errors (captured element.onerror), err is an Event
|
||||
if (!e.target) {
|
||||
message = 'Unexpected unknown error'
|
||||
} else if (!e.target.error) {
|
||||
message = 'Unexpected resource loading error: ' + getElemString(e.target)
|
||||
} else {
|
||||
message = 'Resource error ' + getElemString(e.target) + ': ' + e.target.error.code
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof stack !== 'string') stack = 'Unexpected stack: ' + stack
|
||||
if (typeof message !== 'string') message = 'Unexpected message: ' + message
|
||||
|
||||
// Remove the first part of each file path in the stack trace.
|
||||
// - Privacy: remove personal info like C:\Users\<full name>
|
||||
// - Aggregation: this lets us find which stacktraces occur often
|
||||
stack = stack.replace(/\(.*app.asar/g, '(...')
|
||||
stack = stack.replace(/at .*app.asar/g, 'at ...')
|
||||
|
||||
// We need to POST the telemetry object, make sure it stays < 100kb
|
||||
if (telemetry.uncaughtErrors.length > 20) return
|
||||
if (message.length > 1000) message = message.substring(0, 1000)
|
||||
if (stack.length > 1000) stack = stack.substring(0, 1000)
|
||||
|
||||
// Log the app version *at the time of the error*
|
||||
const version = config.APP_VERSION
|
||||
|
||||
telemetry.uncaughtErrors.push({ process: procName, message, stack, version })
|
||||
}
|
||||
|
||||
// Turns a DOM element into a string, eg "DIV.my-class.visible"
|
||||
function getElemString (elem) {
|
||||
let ret = elem.tagName
|
||||
try {
|
||||
ret += '.' + Array.from(elem.classList).join('.')
|
||||
} catch (err) {}
|
||||
return ret
|
||||
}
|
||||
|
||||
// The user pressed play. Did it work, display an error,
|
||||
// open an external player or did user abandon the attempt?
|
||||
function logPlayAttempt (result) {
|
||||
if (!['success', 'error', 'external', 'abandoned'].includes(result)) {
|
||||
return console.error('Unknown play attempt result', result)
|
||||
}
|
||||
|
||||
const attempts = telemetry.playAttempts
|
||||
attempts.total = (attempts.total || 0) + 1
|
||||
attempts[result] = (attempts[result] || 0) + 1
|
||||
}
|
20
src/renderer/lib/time.js
Normal file
@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
calculateEta
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
53
src/renderer/lib/torrent-player.js
Normal file
@ -0,0 +1,53 @@
|
||||
module.exports = {
|
||||
isPlayable,
|
||||
isVideo,
|
||||
isAudio,
|
||||
isTorrent,
|
||||
isMagnetLink,
|
||||
isPlayableTorrentSummary
|
||||
}
|
||||
|
||||
const path = require('path')
|
||||
|
||||
const mediaExtensions = require('./media-extensions')
|
||||
|
||||
// Checks whether a fileSummary or file path is audio/video that we can play,
|
||||
// based on the file extension
|
||||
function isPlayable (file) {
|
||||
return isVideo(file) || isAudio(file)
|
||||
}
|
||||
|
||||
// Checks whether a fileSummary or file path is playable video
|
||||
function isVideo (file) {
|
||||
return mediaExtensions.video.includes(getFileExtension(file))
|
||||
}
|
||||
|
||||
// Checks whether a fileSummary or file path is playable audio
|
||||
function isAudio (file) {
|
||||
return mediaExtensions.audio.includes(getFileExtension(file))
|
||||
}
|
||||
|
||||
// Checks if the argument is either:
|
||||
// - a string that's a valid filename ending in .torrent
|
||||
// - a file object where obj.name is ends in .torrent
|
||||
// - a string that's a magnet link (magnet://...)
|
||||
function isTorrent (file) {
|
||||
return isTorrentFile(file) || isMagnetLink(file)
|
||||
}
|
||||
|
||||
function isTorrentFile (file) {
|
||||
return getFileExtension(file) === '.torrent'
|
||||
}
|
||||
|
||||
function isMagnetLink (link) {
|
||||
return typeof link === 'string' && /^(stream-)?magnet:/.test(link)
|
||||
}
|
||||
|
||||
function getFileExtension (file) {
|
||||
const name = typeof file === 'string' ? file : file.name
|
||||
return path.extname(name).toLowerCase()
|
||||
}
|
||||
|
||||
function isPlayableTorrentSummary (torrentSummary) {
|
||||
return torrentSummary.files && torrentSummary.files.some(isPlayable)
|
||||
}
|
182
src/renderer/lib/torrent-poster.js
Normal file
@ -0,0 +1,182 @@
|
||||
module.exports = torrentPoster
|
||||
|
||||
const captureFrame = require('capture-frame')
|
||||
const path = require('path')
|
||||
|
||||
const mediaExtensions = require('./media-extensions')
|
||||
|
||||
const msgNoSuitablePoster = 'Cannot generate a poster from any files in the torrent'
|
||||
|
||||
function torrentPoster (torrent, cb) {
|
||||
// First, try to use a poster image if available
|
||||
const posterFile = torrent.files.filter(file => /^poster\.(jpg|png|gif)$/.test(file.name))[0]
|
||||
if (posterFile) return extractPoster(posterFile, cb)
|
||||
|
||||
// 'score' each media type based on total size present in torrent
|
||||
const bestScore = ['audio', 'video', 'image'].map(mediaType => ({
|
||||
type: mediaType,
|
||||
size: calculateDataLengthByExtension(torrent, mediaExtensions[mediaType])
|
||||
})).sort((a, b) => b.size - a.size)[0] // sort descending on size
|
||||
|
||||
if (bestScore.size === 0) {
|
||||
// Admit defeat, no video, audio or image had a significant presence
|
||||
return cb(new Error(msgNoSuitablePoster))
|
||||
}
|
||||
|
||||
// Based on which media type is dominant we select the corresponding poster function
|
||||
switch (bestScore.type) {
|
||||
case 'audio':
|
||||
return torrentPosterFromAudio(torrent, cb)
|
||||
case 'image':
|
||||
return torrentPosterFromImage(torrent, cb)
|
||||
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) {
|
||||
const files = filterOnExtension(torrent, extensions)
|
||||
if (files.length === 0) return undefined
|
||||
return files.reduce((a, b) => a.length > b.length ? a : b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter file on a list extension, can be used to find al image files
|
||||
* @param torrent Torrent to filter files from
|
||||
* @param extensions File extensions to filter on
|
||||
* @returns {Array} Array of torrent file objects matching one of the given extensions
|
||||
*/
|
||||
function filterOnExtension (torrent, extensions) {
|
||||
return torrent.files.filter(file => {
|
||||
const extname = path.extname(file.name).toLowerCase()
|
||||
return extensions.indexOf(extname) !== -1
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a score how likely the file is suitable as a poster
|
||||
* @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)
|
||||
|
||||
const index = torrent.files.indexOf(file)
|
||||
|
||||
const server = torrent.createServer(0)
|
||||
server.listen(0, onListening)
|
||||
|
||||
function onListening () {
|
||||
const port = server.address().port
|
||||
const url = 'http://localhost:' + port + '/' + index
|
||||
const video = document.createElement('video')
|
||||
video.addEventListener('canplay', onCanPlay)
|
||||
|
||||
video.volume = 0
|
||||
video.src = url
|
||||
video.play()
|
||||
|
||||
function onCanPlay () {
|
||||
video.removeEventListener('canplay', onCanPlay)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
|
||||
video.currentTime = Math.min((video.duration || 600) * 0.03, 60)
|
||||
}
|
||||
|
||||
function onSeeked () {
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
|
||||
const frame = captureFrame(video)
|
||||
const buf = frame && frame.image
|
||||
|
||||
// unload video element
|
||||
video.pause()
|
||||
video.src = ''
|
||||
video.load()
|
||||
|
||||
server.destroy()
|
||||
|
||||
if (buf.length === 0) return cb(new Error(msgNoSuitablePoster))
|
||||
|
||||
cb(null, buf, '.jpg')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function torrentPosterFromImage (torrent, cb) {
|
||||
const file = getLargestFileByExtension(torrent, mediaExtensions.image)
|
||||
extractPoster(file, cb)
|
||||
}
|
||||
|
||||
function extractPoster (file, cb) {
|
||||
const extname = path.extname(file.name)
|
||||
file.getBuffer((err, buf) => cb(err, buf, extname))
|
||||
}
|
58
src/renderer/lib/torrent-summary.js
Normal file
@ -0,0 +1,58 @@
|
||||
module.exports = {
|
||||
getPosterPath,
|
||||
getTorrentPath,
|
||||
getByKey,
|
||||
getTorrentId,
|
||||
getFileOrFolder
|
||||
}
|
||||
|
||||
const path = require('path')
|
||||
const config = require('../../config')
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the torrent file, or null if unavailable
|
||||
function getTorrentPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.torrentFileName) return null
|
||||
return path.join(config.TORRENT_PATH, torrentSummary.torrentFileName)
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns an absolute path to the poster image, or null if unavailable
|
||||
function getPosterPath (torrentSummary) {
|
||||
if (!torrentSummary || !torrentSummary.posterFileName) return null
|
||||
const posterPath = path.join(config.POSTER_PATH, torrentSummary.posterFileName)
|
||||
// Work around a Chrome bug (reproduced in vanilla Chrome, not just Electron):
|
||||
// Backslashes in URLS in CSS cause bizarre string encoding issues
|
||||
return posterPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
// Expects a torrentSummary
|
||||
// Returns a torrentID: filename, magnet URI, or infohash
|
||||
function getTorrentId (torrentSummary) {
|
||||
const s = torrentSummary
|
||||
if (s.torrentFileName) { // Load torrent file from disk
|
||||
return getTorrentPath(s)
|
||||
} else { // Load torrent from DHT
|
||||
return s.magnetURI || s.infoHash
|
||||
}
|
||||
}
|
||||
|
||||
// Expects a torrentKey or infoHash
|
||||
// Returns the corresponding torrentSummary, or undefined
|
||||
function getByKey (state, torrentKey) {
|
||||
if (!torrentKey) return undefined
|
||||
return state.saved.torrents.find((x) =>
|
||||
x.torrentKey === torrentKey || x.infoHash === torrentKey)
|
||||
}
|
||||
|
||||
// Returns the path to either the file (in a single-file torrent) or the root
|
||||
// folder (in multi-file torrent)
|
||||
// WARNING: assumes that multi-file torrents consist of a SINGLE folder.
|
||||
// TODO: make this assumption explicit, enforce it in the `create-torrent`
|
||||
// module. Store root folder explicitly to avoid hacky path processing below.
|
||||
function getFileOrFolder (torrentSummary) {
|
||||
const ts = torrentSummary
|
||||
if (!ts.path || !ts.files || ts.files.length === 0) return null
|
||||
const dirname = ts.files[0].path.split(path.sep)[0]
|
||||
return path.join(ts.path, dirname)
|
||||
}
|
607
src/renderer/main.js
Normal file
@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Perf optimization: Hook into require() to modify how certain modules load:
|
||||
*
|
||||
* - `inline-style-prefixer` (used by `material-ui`) takes ~40ms. It is not
|
||||
* actually used because auto-prefixing is disabled with
|
||||
* `darkBaseTheme.userAgent = false`. Return a fake object.
|
||||
*/
|
||||
const Module = require('module')
|
||||
const _require = Module.prototype.require
|
||||
Module.prototype.require = function (id) {
|
||||
if (id === 'inline-style-prefixer') return {}
|
||||
return _require.apply(this, arguments)
|
||||
}
|
||||
|
||||
console.time('init')
|
||||
|
||||
// Perf optimization: Start asynchronously read on config file before all the
|
||||
// blocking require() calls below.
|
||||
|
||||
const State = require('./lib/state')
|
||||
State.load(onState)
|
||||
|
||||
const createGetter = require('fn-getter')
|
||||
const debounce = require('debounce')
|
||||
const dragDrop = require('drag-drop')
|
||||
const electron = require('electron')
|
||||
const fs = require('fs')
|
||||
const React = require('react')
|
||||
const ReactDOM = require('react-dom')
|
||||
|
||||
const config = require('../config')
|
||||
const telemetry = require('./lib/telemetry')
|
||||
const sound = require('./lib/sound')
|
||||
const TorrentPlayer = require('./lib/torrent-player')
|
||||
|
||||
// Perf optimization: Needed immediately, so do not lazy load it below
|
||||
const TorrentListController = require('./controllers/torrent-list-controller')
|
||||
|
||||
const App = require('./pages/app')
|
||||
|
||||
// Electron apps have two processes: a main process (node) runs first and starts
|
||||
// a renderer process (essentially a Chrome window). We're in the renderer process,
|
||||
// and this IPC channel receives from and sends messages to the main process
|
||||
const ipcRenderer = electron.ipcRenderer
|
||||
|
||||
// Yo-yo pattern: state object lives here and percolates down thru all the views.
|
||||
// Events come back up from the views via dispatch(...)
|
||||
require('./lib/dispatcher').setDispatch(dispatch)
|
||||
|
||||
// From dispatch(...), events are sent to one of the controllers
|
||||
let controllers = null
|
||||
|
||||
// This dependency is the slowest-loading, so we lazy load it
|
||||
let Cast = null
|
||||
|
||||
// All state lives in state.js. `state.saved` is read from and written to a file.
|
||||
// All other state is ephemeral. First we load state.saved then initialize the app.
|
||||
let state
|
||||
|
||||
// Root React component
|
||||
let app
|
||||
|
||||
// Called once when the application loads. (Not once per window.)
|
||||
// Connects to the torrent networks, sets up the UI and OS integrations like
|
||||
// the dock icon and drag+drop.
|
||||
function onState (err, _state) {
|
||||
if (err) return onError(err)
|
||||
|
||||
// Make available for easier debugging
|
||||
state = window.state = _state
|
||||
window.dispatch = dispatch
|
||||
|
||||
telemetry.init(state)
|
||||
sound.init(state)
|
||||
|
||||
// Log uncaught JS errors
|
||||
window.addEventListener(
|
||||
'error', (e) => telemetry.logUncaughtError('window', e), true /* capture */
|
||||
)
|
||||
|
||||
// Create controllers
|
||||
controllers = {
|
||||
media: createGetter(() => {
|
||||
const MediaController = require('./controllers/media-controller')
|
||||
return new MediaController(state)
|
||||
}),
|
||||
playback: createGetter(() => {
|
||||
const PlaybackController = require('./controllers/playback-controller')
|
||||
return new PlaybackController(state, config, update)
|
||||
}),
|
||||
prefs: createGetter(() => {
|
||||
const PrefsController = require('./controllers/prefs-controller')
|
||||
return new PrefsController(state, config)
|
||||
}),
|
||||
subtitles: createGetter(() => {
|
||||
const SubtitlesController = require('./controllers/subtitles-controller')
|
||||
return new SubtitlesController(state)
|
||||
}),
|
||||
audioTracks: createGetter(() => {
|
||||
const AudioTracksController = require('./controllers/audio-tracks-controller')
|
||||
return new AudioTracksController(state)
|
||||
}),
|
||||
torrent: createGetter(() => {
|
||||
const TorrentController = require('./controllers/torrent-controller')
|
||||
return new TorrentController(state)
|
||||
}),
|
||||
torrentList: createGetter(() => new TorrentListController(state)),
|
||||
update: createGetter(() => {
|
||||
const UpdateController = require('./controllers/update-controller')
|
||||
return new UpdateController(state)
|
||||
}),
|
||||
folderWatcher: createGetter(() => {
|
||||
const FolderWatcherController = require('./controllers/folder-watcher-controller')
|
||||
return new FolderWatcherController()
|
||||
})
|
||||
}
|
||||
|
||||
// Add first page to location history
|
||||
state.location.go({
|
||||
url: 'home',
|
||||
setup: (cb) => {
|
||||
state.window.title = config.APP_WINDOW_TITLE
|
||||
cb(null)
|
||||
}
|
||||
})
|
||||
|
||||
// Give global trackers
|
||||
setGlobalTrackers()
|
||||
|
||||
// Restart everything we were torrenting last time the app ran
|
||||
resumeTorrents()
|
||||
|
||||
// Initialize ReactDOM
|
||||
ReactDOM.render(
|
||||
<App state={state} ref={elem => { app = elem }} />,
|
||||
document.querySelector('#body')
|
||||
)
|
||||
|
||||
// Calling update() updates the UI given the current state
|
||||
// Do this at least once a second to give every file in every torrentSummary
|
||||
// a progress bar and to keep the cursor in sync when playing a video
|
||||
setInterval(update, 1000)
|
||||
|
||||
// Listen for messages from the main process
|
||||
setupIpc()
|
||||
|
||||
// Drag and drop files/text to start torrenting or seeding
|
||||
dragDrop('body', {
|
||||
onDrop: onOpen,
|
||||
onDropText: onOpen
|
||||
})
|
||||
|
||||
// ...same thing if you paste a torrent
|
||||
document.addEventListener('paste', onPaste)
|
||||
|
||||
// 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
|
||||
window.addEventListener('focus', onFocus)
|
||||
window.addEventListener('blur', onBlur)
|
||||
|
||||
if (electron.remote.getCurrentWindow().isVisible()) {
|
||||
sound.play('STARTUP')
|
||||
}
|
||||
|
||||
// To keep app startup fast, some code is delayed.
|
||||
window.setTimeout(delayedInit, config.DELAYED_INIT)
|
||||
|
||||
// Done! Ideally we want to get here < 500ms after the user clicks the app
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
// Runs a few seconds after the app loads, to avoid slowing down startup time
|
||||
function delayedInit () {
|
||||
telemetry.send(state)
|
||||
|
||||
// Send telemetry data every 12 hours, for users who keep the app running
|
||||
// for extended periods of time
|
||||
setInterval(() => telemetry.send(state), 12 * 3600 * 1000)
|
||||
|
||||
// Warn if the download dir is gone, eg b/c an external drive is unplugged
|
||||
checkDownloadPath()
|
||||
|
||||
// ...window visibility state.
|
||||
document.addEventListener('webkitvisibilitychange', onVisibilityChange)
|
||||
onVisibilityChange()
|
||||
|
||||
lazyLoadCast()
|
||||
}
|
||||
|
||||
// Lazily loads Chromecast and Airplay support
|
||||
function lazyLoadCast () {
|
||||
if (!Cast) {
|
||||
Cast = require('./lib/cast')
|
||||
Cast.init(state, update) // Search the local network for Chromecast and Airplays
|
||||
}
|
||||
return Cast
|
||||
}
|
||||
|
||||
// React loop:
|
||||
// 1. update() - recompute the virtual DOM, diff, apply to the real DOM
|
||||
// 2. event - might be a click or other DOM event, or something external
|
||||
// 3. dispatch - the event handler calls dispatch(), main.js sends it to a controller
|
||||
// 4. controller - the controller handles the event, changing the state object
|
||||
function update () {
|
||||
controllers.playback().showOrHidePlayerControls()
|
||||
app.setState(state)
|
||||
updateElectron()
|
||||
}
|
||||
|
||||
// Some state changes can't be reflected in the DOM, instead we have to
|
||||
// tell the main process to update the window or OS integrations
|
||||
function updateElectron () {
|
||||
if (state.window.title !== state.prev.title) {
|
||||
state.prev.title = state.window.title
|
||||
ipcRenderer.send('setTitle', state.window.title)
|
||||
}
|
||||
if (state.dock.progress.toFixed(2) !== state.prev.progress.toFixed(2)) {
|
||||
state.prev.progress = state.dock.progress
|
||||
ipcRenderer.send('setProgress', state.dock.progress)
|
||||
}
|
||||
if (state.dock.badge !== state.prev.badge) {
|
||||
state.prev.badge = state.dock.badge
|
||||
ipcRenderer.send('setBadge', state.dock.badge || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchHandlers = {
|
||||
// Torrent list: creating, deleting, selecting torrents
|
||||
openTorrentFile: () => ipcRenderer.send('openTorrentFile'),
|
||||
openFiles: () => ipcRenderer.send('openFiles'), /* shows the open file dialog */
|
||||
openTorrentAddress: () => { state.modal = { id: 'open-torrent-address-modal' } },
|
||||
|
||||
addTorrent: (torrentId) => controllers.torrentList().addTorrent(torrentId),
|
||||
showCreateTorrent: (paths) => controllers.torrentList().showCreateTorrent(paths),
|
||||
createTorrent: (options) => controllers.torrentList().createTorrent(options),
|
||||
toggleTorrent: (infoHash) => controllers.torrentList().toggleTorrent(infoHash),
|
||||
pauseAllTorrents: () => controllers.torrentList().pauseAllTorrents(),
|
||||
resumeAllTorrents: () => controllers.torrentList().resumeAllTorrents(),
|
||||
toggleTorrentFile: (infoHash, index) =>
|
||||
controllers.torrentList().toggleTorrentFile(infoHash, index),
|
||||
confirmDeleteTorrent: (infoHash, deleteData) =>
|
||||
controllers.torrentList().confirmDeleteTorrent(infoHash, deleteData),
|
||||
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
|
||||
playFile: (infoHash, index) => controllers.playback().playFile(infoHash, index),
|
||||
playPause: () => controllers.playback().playPause(),
|
||||
nextTrack: () => controllers.playback().nextTrack(),
|
||||
previousTrack: () => controllers.playback().previousTrack(),
|
||||
skip: (time) => controllers.playback().skip(time),
|
||||
skipTo: (time) => controllers.playback().skipTo(time),
|
||||
preview: (x) => controllers.playback().preview(x),
|
||||
clearPreview: () => controllers.playback().clearPreview(),
|
||||
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
|
||||
openSubtitles: () => controllers.subtitles().openSubtitles(),
|
||||
selectSubtitle: (index) => controllers.subtitles().selectSubtitle(index),
|
||||
toggleSubtitlesMenu: () => controllers.subtitles().toggleSubtitlesMenu(),
|
||||
checkForSubtitles: () => controllers.subtitles().checkForSubtitles(),
|
||||
addSubtitles: (files, autoSelect) => controllers.subtitles().addSubtitles(files, autoSelect),
|
||||
|
||||
// Audio Tracks
|
||||
selectAudioTrack: (index) => controllers.audioTracks().selectAudioTrack(index),
|
||||
toggleAudioTracksMenu: () => controllers.audioTracks().toggleAudioTracksMenu(),
|
||||
|
||||
// Local media: <video>, <audio>, external players
|
||||
mediaStalled: () => controllers.media().mediaStalled(),
|
||||
mediaError: (err) => controllers.media().mediaError(err),
|
||||
mediaSuccess: () => controllers.media().mediaSuccess(),
|
||||
mediaTimeUpdate: () => controllers.media().mediaTimeUpdate(),
|
||||
mediaMouseMoved: () => controllers.media().mediaMouseMoved(),
|
||||
mediaControlsMouseEnter: () => controllers.media().controlsMouseEnter(),
|
||||
mediaControlsMouseLeave: () => controllers.media().controlsMouseLeave(),
|
||||
openExternalPlayer: () => controllers.media().openExternalPlayer(),
|
||||
externalPlayerNotFound: () => controllers.media().externalPlayerNotFound(),
|
||||
|
||||
// Remote casting: Chromecast, Airplay, etc
|
||||
toggleCastMenu: (deviceType) => lazyLoadCast().toggleMenu(deviceType),
|
||||
selectCastDevice: (index) => lazyLoadCast().selectDevice(index),
|
||||
stopCasting: () => lazyLoadCast().stop(),
|
||||
|
||||
// Preferences screen
|
||||
preferences: () => controllers.prefs().show(),
|
||||
updatePreferences: (key, value) => controllers.prefs().update(key, value),
|
||||
checkDownloadPath,
|
||||
updateGlobalTrackers: (trackers) => setGlobalTrackers(trackers),
|
||||
startFolderWatcher: () => controllers.folderWatcher().start(),
|
||||
stopFolderWatcher: () => controllers.folderWatcher().stop(),
|
||||
|
||||
// Update (check for new versions on Linux, where there's no auto updater)
|
||||
updateAvailable: (version) => controllers.update().updateAvailable(version),
|
||||
skipVersion: (version) => controllers.update().skipVersion(version),
|
||||
|
||||
// Navigation between screens (back, forward, ESC, etc)
|
||||
exitModal: () => { state.modal = null },
|
||||
backToList,
|
||||
escapeBack,
|
||||
back: () => state.location.back(),
|
||||
forward: () => state.location.forward(),
|
||||
cancel: () => state.location.cancel(),
|
||||
|
||||
// Controlling the window
|
||||
setDimensions,
|
||||
toggleFullScreen: (setTo) => ipcRenderer.send('toggleFullScreen', setTo),
|
||||
setTitle: (title) => { state.window.title = title },
|
||||
resetTitle: () => { state.window.title = config.APP_WINDOW_TITLE },
|
||||
|
||||
// Everything else
|
||||
onOpen,
|
||||
error: onError,
|
||||
uncaughtError: (proc, err) => telemetry.logUncaughtError(proc, err),
|
||||
stateSave: () => State.save(state),
|
||||
stateSaveImmediate: () => State.saveImmediate(state),
|
||||
update: () => {} // No-op, just trigger an update
|
||||
}
|
||||
|
||||
// Events from the UI never modify state directly. Instead they call dispatch()
|
||||
function dispatch (action, ...args) {
|
||||
// Log dispatch calls, for debugging, but don't spam
|
||||
if (!['mediaMouseMoved', 'mediaTimeUpdate', 'update'].includes(action)) {
|
||||
console.log('dispatch: %s %o', action, args)
|
||||
}
|
||||
|
||||
const handler = dispatchHandlers[action]
|
||||
if (handler) handler(...args)
|
||||
else console.error('Missing dispatch handler: ' + action)
|
||||
|
||||
// Update the virtual DOM, unless it's just a mouse move event
|
||||
if (action !== 'mediaMouseMoved' ||
|
||||
controllers.playback().showOrHidePlayerControls()) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to events from the main and webtorrent processes
|
||||
function setupIpc () {
|
||||
ipcRenderer.on('log', (e, ...args) => console.log(...args))
|
||||
ipcRenderer.on('error', (e, ...args) => console.error(...args))
|
||||
|
||||
ipcRenderer.on('dispatch', (e, ...args) => dispatch(...args))
|
||||
|
||||
ipcRenderer.on('fullscreenChanged', onFullscreenChanged)
|
||||
ipcRenderer.on('windowBoundsChanged', onWindowBoundsChanged)
|
||||
|
||||
const tc = controllers.torrent()
|
||||
ipcRenderer.on('wt-parsed', (e, ...args) => tc.torrentParsed(...args))
|
||||
ipcRenderer.on('wt-metadata', (e, ...args) => tc.torrentMetadata(...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-error', (e, ...args) => tc.torrentError(...args))
|
||||
|
||||
ipcRenderer.on('wt-progress', (e, ...args) => tc.torrentProgress(...args))
|
||||
ipcRenderer.on('wt-file-modtimes', (e, ...args) => tc.torrentFileModtimes(...args))
|
||||
ipcRenderer.on('wt-file-saved', (e, ...args) => tc.torrentFileSaved(...args))
|
||||
ipcRenderer.on('wt-poster', (e, ...args) => tc.torrentPosterSaved(...args))
|
||||
ipcRenderer.on('wt-audio-metadata', (e, ...args) => tc.torrentAudioMetadata(...args))
|
||||
ipcRenderer.on('wt-server-running', (e, ...args) => tc.torrentServerRunning(...args))
|
||||
|
||||
ipcRenderer.on('wt-uncaught-error', (e, err) => telemetry.logUncaughtError('webtorrent', err))
|
||||
|
||||
ipcRenderer.send('ipcReady')
|
||||
|
||||
State.on('stateSaved', () => ipcRenderer.send('stateSaved'))
|
||||
}
|
||||
|
||||
// Quits any modal popovers and returns to the torrent list screen
|
||||
function backToList () {
|
||||
// Exit any modals and screens with a back button
|
||||
state.modal = null
|
||||
state.location.backToFirst(() => {
|
||||
// If we were already on the torrent list, scroll to the top
|
||||
const contentTag = document.querySelector('.content')
|
||||
if (contentTag) contentTag.scrollTop = 0
|
||||
})
|
||||
}
|
||||
|
||||
// Quits modals, full screen, or goes back. Happens when the user hits ESC
|
||||
function escapeBack () {
|
||||
if (state.modal) {
|
||||
dispatch('exitModal')
|
||||
} else if (state.window.isFullScreen) {
|
||||
dispatch('toggleFullScreen')
|
||||
} else {
|
||||
dispatch('back')
|
||||
}
|
||||
}
|
||||
|
||||
function setGlobalTrackers () {
|
||||
controllers.torrentList().setGlobalTrackers(state.getGlobalTrackers())
|
||||
}
|
||||
|
||||
// Starts all torrents that aren't paused on program startup
|
||||
function resumeTorrents () {
|
||||
state.saved.torrents
|
||||
.map((torrentSummary) => {
|
||||
// Torrent keys are ephemeral, reassigned each time the app runs.
|
||||
// On startup, give all torrents a key, even the ones that are paused.
|
||||
torrentSummary.torrentKey = state.nextTorrentKey++
|
||||
return torrentSummary
|
||||
})
|
||||
.filter((s) => s.status !== 'paused')
|
||||
.forEach((s) => controllers.torrentList().startTorrentingSummary(s.torrentKey))
|
||||
}
|
||||
|
||||
// Set window dimensions to match video dimensions or fill the screen
|
||||
function setDimensions (dimensions) {
|
||||
// Don't modify the window size if it's already maximized
|
||||
if (electron.remote.getCurrentWindow().isMaximized()) {
|
||||
state.window.bounds = null
|
||||
return
|
||||
}
|
||||
|
||||
// Save the bounds of the window for later. See restoreBounds()
|
||||
state.window.bounds = {
|
||||
x: window.screenX,
|
||||
y: window.screenY,
|
||||
width: window.outerWidth,
|
||||
height: window.outerHeight
|
||||
}
|
||||
state.window.wasMaximized = electron.remote.getCurrentWindow().isMaximized
|
||||
|
||||
// Limit window size to screen size
|
||||
const screenWidth = window.screen.width
|
||||
const screenHeight = window.screen.height
|
||||
const aspectRatio = dimensions.width / dimensions.height
|
||||
const scaleFactor = Math.min(
|
||||
Math.min(screenWidth / dimensions.width, 1),
|
||||
Math.min(screenHeight / dimensions.height, 1)
|
||||
)
|
||||
const width = Math.max(
|
||||
Math.floor(dimensions.width * scaleFactor),
|
||||
config.WINDOW_MIN_WIDTH
|
||||
)
|
||||
const height = Math.max(
|
||||
Math.floor(dimensions.height * scaleFactor),
|
||||
config.WINDOW_MIN_HEIGHT
|
||||
)
|
||||
|
||||
ipcRenderer.send('setAspectRatio', aspectRatio)
|
||||
ipcRenderer.send('setBounds', { contentBounds: true, x: null, y: null, width, height })
|
||||
state.playing.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
// Called when the user adds files (.torrent, files to seed, subtitles) to the app
|
||||
// via any method (drag-drop, drag to app icon, command line)
|
||||
function onOpen (files) {
|
||||
if (!Array.isArray(files)) files = [files]
|
||||
|
||||
// File API seems to transform "magnet:?foo" in "magnet:///?foo"
|
||||
// this is a sanitization
|
||||
files = files.map(file => {
|
||||
if (typeof file !== 'string') return file
|
||||
return file.replace(/^magnet:\/+\?/i, 'magnet:?')
|
||||
})
|
||||
|
||||
const url = state.location.url()
|
||||
const allTorrents = files.every(TorrentPlayer.isTorrent)
|
||||
const allSubtitles = files.every(controllers.subtitles().isSubtitle)
|
||||
|
||||
if (allTorrents) {
|
||||
// Drop torrents onto the app: go to home screen, add torrents, no matter what
|
||||
dispatch('backToList')
|
||||
// All .torrent files? Add them.
|
||||
files.forEach((file) => controllers.torrentList().addTorrent(file))
|
||||
} else if (url === 'player' && allSubtitles) {
|
||||
// Drop subtitles onto a playing video: add subtitles
|
||||
controllers.subtitles().addSubtitles(files, true)
|
||||
} else if (url === 'home') {
|
||||
// Drop files onto home screen: show Create Torrent
|
||||
state.modal = null
|
||||
controllers.torrentList().showCreateTorrent(files)
|
||||
} else {
|
||||
// Drop files onto any other screen: show error
|
||||
return onError('Please go back to the torrent list before creating a new torrent.')
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function onError (err) {
|
||||
console.error(err.stack || err)
|
||||
sound.play('ERROR')
|
||||
state.errors.push({
|
||||
time: new Date().getTime(),
|
||||
message: err.message || err
|
||||
})
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
const editableHtmlTags = new Set(['input', 'textarea'])
|
||||
|
||||
function onPaste (e) {
|
||||
if (e && editableHtmlTags.has(e.target.tagName.toLowerCase())) return
|
||||
controllers.torrentList().addTorrent(electron.clipboard.readText())
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function onKeydown (e) {
|
||||
// prevent event fire on user input elements
|
||||
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()
|
||||
}
|
||||
|
||||
function onFocus (e) {
|
||||
state.window.isFocused = true
|
||||
state.dock.badge = 0
|
||||
update()
|
||||
}
|
||||
|
||||
function onBlur () {
|
||||
state.window.isFocused = false
|
||||
update()
|
||||
}
|
||||
|
||||
function onVisibilityChange () {
|
||||
state.window.isVisible = !document.hidden
|
||||
}
|
||||
|
||||
function onFullscreenChanged (e, isFullScreen) {
|
||||
state.window.isFullScreen = isFullScreen
|
||||
if (!isFullScreen) {
|
||||
// Aspect ratio gets reset in fullscreen mode, so restore it (Mac)
|
||||
ipcRenderer.send('setAspectRatio', state.playing.aspectRatio)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
function onWindowBoundsChanged (e, newBounds) {
|
||||
if (state.location.url() !== 'player') {
|
||||
state.saved.bounds = newBounds
|
||||
dispatch('stateSave')
|
||||
}
|
||||
}
|
||||
|
||||
function checkDownloadPath () {
|
||||
fs.stat(state.saved.prefs.downloadPath, (err, stat) => {
|
||||
if (err) {
|
||||
state.downloadPathStatus = 'missing'
|
||||
return console.error(err)
|
||||
}
|
||||
if (stat.isDirectory()) state.downloadPathStatus = 'ok'
|
||||
else state.downloadPathStatus = 'missing'
|
||||
})
|
||||
}
|
132
src/renderer/pages/app.js
Normal file
@ -0,0 +1,132 @@
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const createGetter = require('fn-getter')
|
||||
const React = require('react')
|
||||
|
||||
const darkBaseTheme = require('material-ui/styles/baseThemes/darkBaseTheme').default
|
||||
const getMuiTheme = require('material-ui/styles/getMuiTheme').default
|
||||
const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default
|
||||
|
||||
const Header = require('../components/header')
|
||||
|
||||
// Perf optimization: Needed immediately, so do not lazy load it below
|
||||
const TorrentListPage = require('./torrent-list-page')
|
||||
|
||||
const Views = {
|
||||
home: createGetter(() => TorrentListPage),
|
||||
player: createGetter(() => require('./player-page')),
|
||||
'create-torrent': createGetter(() => require('./create-torrent-page')),
|
||||
preferences: createGetter(() => require('./preferences-page'))
|
||||
}
|
||||
|
||||
const Modals = {
|
||||
'open-torrent-address-modal': createGetter(
|
||||
() => require('../components/open-torrent-address-modal')
|
||||
),
|
||||
'remove-torrent-modal': createGetter(() => require('../components/remove-torrent-modal')),
|
||||
'update-available-modal': createGetter(() => require('../components/update-available-modal')),
|
||||
'unsupported-media-modal': createGetter(() => require('../components/unsupported-media-modal')),
|
||||
'delete-all-torrents-modal':
|
||||
createGetter(() => require('../components/delete-all-torrents-modal'))
|
||||
}
|
||||
|
||||
const fontFamily = process.platform === 'win32'
|
||||
? '"Segoe UI", sans-serif'
|
||||
: 'BlinkMacSystemFont, "Helvetica Neue", Helvetica, sans-serif'
|
||||
|
||||
darkBaseTheme.fontFamily = fontFamily
|
||||
darkBaseTheme.userAgent = false
|
||||
darkBaseTheme.palette.primary1Color = colors.grey50
|
||||
darkBaseTheme.palette.primary2Color = colors.grey50
|
||||
darkBaseTheme.palette.primary3Color = colors.grey600
|
||||
darkBaseTheme.palette.accent1Color = colors.redA200
|
||||
darkBaseTheme.palette.accent2Color = colors.redA400
|
||||
darkBaseTheme.palette.accent3Color = colors.redA100
|
||||
|
||||
let darkMuiTheme
|
||||
let lightMuiTheme
|
||||
|
||||
class App extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
|
||||
// Hide player controls while playing video, if the mouse stays still for a while
|
||||
// Never hide the controls when:
|
||||
// * The mouse is over the controls or we're scrubbing (see CSS)
|
||||
// * The video is paused
|
||||
// * The video is playing remotely on Chromecast or Airplay
|
||||
const hideControls = state.shouldHidePlayerControls()
|
||||
|
||||
const cls = [
|
||||
'view-' + state.location.url(), /* e.g. view-home, view-player */
|
||||
'is-' + process.platform /* e.g. is-darwin, is-win32, is-linux */
|
||||
]
|
||||
if (state.window.isFullScreen) cls.push('is-fullscreen')
|
||||
if (state.window.isFocused) cls.push('is-focused')
|
||||
if (hideControls) cls.push('hide-video-controls')
|
||||
|
||||
if (!darkMuiTheme) {
|
||||
darkMuiTheme = getMuiTheme(darkBaseTheme)
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiThemeProvider muiTheme={darkMuiTheme}>
|
||||
<div className={'app ' + cls.join(' ')}>
|
||||
<Header state={state} />
|
||||
{this.getErrorPopover()}
|
||||
<div key='content' className='content'>{this.getView()}</div>
|
||||
{this.getModal()}
|
||||
</div>
|
||||
</MuiThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
getErrorPopover () {
|
||||
const state = this.props.state
|
||||
const now = new Date().getTime()
|
||||
const recentErrors = state.errors.filter((x) => now - x.time < 5000)
|
||||
const hasErrors = recentErrors.length > 0
|
||||
|
||||
const errorElems = recentErrors.map((error, i) => <div key={i} className='error'>{error.message}</div>)
|
||||
return (
|
||||
<div
|
||||
key='errors'
|
||||
className={'error-popover ' + (hasErrors ? 'visible' : 'hidden')}
|
||||
>
|
||||
<div key='title' className='title'>Error</div>
|
||||
{errorElems}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
getModal () {
|
||||
const state = this.props.state
|
||||
if (!state.modal) return
|
||||
|
||||
if (!lightMuiTheme) {
|
||||
const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default
|
||||
lightBaseTheme.fontFamily = fontFamily
|
||||
lightBaseTheme.userAgent = false
|
||||
lightMuiTheme = getMuiTheme(lightBaseTheme)
|
||||
}
|
||||
|
||||
const ModalContents = Modals[state.modal.id]()
|
||||
return (
|
||||
<MuiThemeProvider muiTheme={lightMuiTheme}>
|
||||
<div key='modal' className='modal'>
|
||||
<div key='modal-background' className='modal-background' />
|
||||
<div key='modal-content' className='modal-content'>
|
||||
<ModalContents state={state} />
|
||||
</div>
|
||||
</div>
|
||||
</MuiThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
getView () {
|
||||
const state = this.props.state
|
||||
const View = Views[state.location.url()]()
|
||||
return (<View state={state} />)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = App
|
227
src/renderer/pages/create-torrent-page.js
Normal file
@ -0,0 +1,227 @@
|
||||
const createTorrent = require('create-torrent')
|
||||
const path = require('path')
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
const React = require('react')
|
||||
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
const FlatButton = require('material-ui/FlatButton').default
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
const TextField = require('material-ui/TextField').default
|
||||
const Checkbox = require('material-ui/Checkbox').default
|
||||
|
||||
const CreateTorrentErrorPage = require('../components/create-torrent-error-page')
|
||||
const Heading = require('../components/heading')
|
||||
const ShowMore = require('../components/show-more')
|
||||
|
||||
// Shows a basic UI to create a torrent, from an already-selected file or folder.
|
||||
// Includes a "Show Advanced..." button and more advanced UI.
|
||||
class CreateTorrentPage extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
const state = this.props.state
|
||||
const info = state.location.current()
|
||||
|
||||
// First, extract the base folder that the files are all in
|
||||
let pathPrefix = info.folderPath
|
||||
if (!pathPrefix) {
|
||||
pathPrefix = info.files.map((x) => x.path).reduce(findCommonPrefix)
|
||||
if (!pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) {
|
||||
pathPrefix = path.dirname(pathPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Then, exclude .DS_Store and other dotfiles
|
||||
const files = info.files
|
||||
.filter((f) => !containsDots(f.path, pathPrefix))
|
||||
.map((f) => ({ name: f.name, path: f.path, size: f.size }))
|
||||
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
|
||||
|
||||
// Then, use the name of the base folder (or sole file, for a single file torrent)
|
||||
// as the default name. Show all files relative to the base folder.
|
||||
let defaultName, basePath
|
||||
if (files.length === 1) {
|
||||
// Single file torrent: /a/b/foo.jpg -> torrent name 'foo.jpg', path '/a/b'
|
||||
defaultName = files[0].name
|
||||
basePath = pathPrefix
|
||||
} else {
|
||||
// Multi file torrent: /a/b/{foo, bar}.jpg -> torrent name 'b', path '/a'
|
||||
defaultName = path.basename(pathPrefix)
|
||||
basePath = path.dirname(pathPrefix)
|
||||
}
|
||||
|
||||
// Default trackers
|
||||
const trackers = createTorrent.announceList.join('\n')
|
||||
|
||||
this.state = {
|
||||
comment: '',
|
||||
isPrivate: false,
|
||||
pathPrefix,
|
||||
basePath,
|
||||
defaultName,
|
||||
files,
|
||||
trackers
|
||||
}
|
||||
|
||||
// Create React event handlers only once
|
||||
this.handleSetIsPrivate = (_, isPrivate) => this.setState({ isPrivate })
|
||||
this.handleSetComment = (_, comment) => this.setState({ comment })
|
||||
this.handleSetTrackers = (_, trackers) => this.setState({ trackers })
|
||||
this.handleSubmit = handleSubmit.bind(this)
|
||||
}
|
||||
|
||||
render () {
|
||||
const files = this.state.files
|
||||
|
||||
// Sanity check: show the number of files and total size
|
||||
const numFiles = files.length
|
||||
const totalBytes = files
|
||||
.map((f) => f.size)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
const torrentInfo = `${numFiles} files, ${prettyBytes(totalBytes)}`
|
||||
|
||||
return (
|
||||
<div className='create-torrent'>
|
||||
<Heading level={1}>Create torrent {this.state.defaultName}</Heading>
|
||||
<div className='torrent-info'>{torrentInfo}</div>
|
||||
<div className='torrent-attribute'>
|
||||
<label>Path:</label>
|
||||
<div>{this.state.pathPrefix}</div>
|
||||
</div>
|
||||
<ShowMore
|
||||
style={{
|
||||
marginBottom: 10
|
||||
}}
|
||||
hideLabel='Hide advanced settings...'
|
||||
showLabel='Show advanced settings...'
|
||||
>
|
||||
{this.renderAdvanced()}
|
||||
</ShowMore>
|
||||
<div className='float-right'>
|
||||
<FlatButton
|
||||
className='control cancel'
|
||||
label='Cancel'
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
onClick={dispatcher('cancel')}
|
||||
/>
|
||||
<RaisedButton
|
||||
className='control create-torrent-button'
|
||||
label='Create Torrent'
|
||||
primary
|
||||
onClick={this.handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renders everything after clicking Show Advanced...:
|
||||
// * Is Private? (private torrents, not announced to DHT)
|
||||
// * Announce list (trackers)
|
||||
// * Comment
|
||||
renderAdvanced () {
|
||||
// Create file list
|
||||
const maxFileElems = 100
|
||||
const files = this.state.files
|
||||
const fileElems = files.slice(0, maxFileElems).map((file, i) => {
|
||||
const relativePath = path.relative(this.state.pathPrefix, file.path)
|
||||
return (<div key={i}>{relativePath}</div>)
|
||||
})
|
||||
if (files.length > maxFileElems) {
|
||||
fileElems.push(<div key='more'>+ {files.length - maxFileElems} more</div>)
|
||||
}
|
||||
|
||||
// Align the text fields
|
||||
const textFieldStyle = { width: '' }
|
||||
const textareaStyle = { margin: 0 }
|
||||
|
||||
return (
|
||||
<div key='advanced' className='create-torrent-advanced'>
|
||||
<div key='private' className='torrent-attribute'>
|
||||
<label>Private:</label>
|
||||
<Checkbox
|
||||
className='torrent-is-private control'
|
||||
style={{ display: '' }}
|
||||
checked={this.state.isPrivate}
|
||||
onCheck={this.handleSetIsPrivate}
|
||||
/>
|
||||
</div>
|
||||
<div key='trackers' className='torrent-attribute'>
|
||||
<label>Trackers:</label>
|
||||
<TextField
|
||||
className='torrent-trackers control'
|
||||
style={textFieldStyle}
|
||||
textareaStyle={textareaStyle}
|
||||
multiLine
|
||||
rows={2}
|
||||
rowsMax={10}
|
||||
value={this.state.trackers}
|
||||
onChange={this.handleSetTrackers}
|
||||
/>
|
||||
</div>
|
||||
<div key='comment' className='torrent-attribute'>
|
||||
<label>Comment:</label>
|
||||
<TextField
|
||||
className='torrent-comment control'
|
||||
style={textFieldStyle}
|
||||
textareaStyle={textareaStyle}
|
||||
hintText='Optionally describe your torrent...'
|
||||
multiLine
|
||||
rows={2}
|
||||
rowsMax={10}
|
||||
value={this.state.comment}
|
||||
onChange={this.handleSetComment}
|
||||
/>
|
||||
</div>
|
||||
<div key='files' className='torrent-attribute'>
|
||||
<label>Files:</label>
|
||||
<div>{fileElems}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit () {
|
||||
const announceList = this.state.trackers
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
const options = {
|
||||
// We can't let the user choose their own name if we want WebTorrent
|
||||
// to use the files in place rather than creating a new folder.
|
||||
name: this.state.defaultName,
|
||||
path: this.state.basePath,
|
||||
files: this.state.files,
|
||||
announce: announceList,
|
||||
comment: this.state.comment.trim()
|
||||
}
|
||||
|
||||
// If torrent is not private, leave private flag unset. This ensures that
|
||||
// the torrent info hash will match the result generated by other tools,
|
||||
// including webtorrent-cli.
|
||||
if (this.state.isPrivate) options.private = true
|
||||
|
||||
dispatch('createTorrent', options)
|
||||
}
|
||||
|
||||
// Finds the longest common prefix
|
||||
function findCommonPrefix (a, b) {
|
||||
let i
|
||||
for (i = 0; i < a.length && i < b.length; i++) {
|
||||
if (a.charCodeAt(i) !== b.charCodeAt(i)) break
|
||||
}
|
||||
if (i === a.length) return a
|
||||
if (i === b.length) return b
|
||||
return a.substring(0, i)
|
||||
}
|
||||
|
||||
function containsDots (path, pathPrefix) {
|
||||
const suffix = path.substring(pathPrefix.length).replace(/\\/g, '/')
|
||||
return ('/' + suffix).includes('/.')
|
||||
}
|
||||
|
||||
module.exports = CreateTorrentPage
|
993
src/renderer/pages/player-page.js
Normal file
@ -0,0 +1,993 @@
|
||||
/* globals MediaMetadata */
|
||||
|
||||
const React = require('react')
|
||||
const BitField = require('bitfield').default
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const Playlist = require('../lib/playlist')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
const config = require('../../config')
|
||||
const { calculateEta } = require('../lib/time')
|
||||
|
||||
// Shows a streaming video player. Standard features + Chromecast + Airplay
|
||||
module.exports = class Player extends React.Component {
|
||||
render () {
|
||||
// Show the video as large as will fit in the window, play immediately
|
||||
// If the video is on Chromecast or Airplay, show a title screen instead
|
||||
const state = this.props.state
|
||||
const showVideo = state.playing.location === 'local'
|
||||
const showControls = state.playing.location !== 'external'
|
||||
return (
|
||||
<div
|
||||
className='player'
|
||||
onWheel={handleVolumeWheel}
|
||||
onMouseMove={dispatcher('mediaMouseMoved')}
|
||||
>
|
||||
{showVideo ? renderMedia(state) : renderCastScreen(state)}
|
||||
{showControls ? renderPlayerControls(state) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onComponentWillUnmount () {
|
||||
// Unload the media element so that Chromium stops trying to fetch data
|
||||
const tag = document.querySelector('audio,video')
|
||||
if (!tag) return
|
||||
tag.pause()
|
||||
tag.src = ''
|
||||
tag.load()
|
||||
navigator.mediaSession.metadata = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handles volume change by wheel
|
||||
function handleVolumeWheel (e) {
|
||||
dispatch('changeVolume', (-e.deltaY | e.deltaX) / 500)
|
||||
}
|
||||
|
||||
function renderMedia (state) {
|
||||
if (!state.server) return
|
||||
|
||||
// Unfortunately, play/pause can't be done just by modifying HTML.
|
||||
// Instead, grab the DOM node and play/pause it if necessary
|
||||
// Get the <video> or <audio> tag
|
||||
const mediaElement = document.querySelector(state.playing.type)
|
||||
if (mediaElement !== null) {
|
||||
if (navigator.mediaSession.metadata === null && mediaElement.played.length !== 0) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: state.playing.fileName
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
dispatch('playPause')
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
dispatch('playPause')
|
||||
})
|
||||
if (Playlist.hasNext(state)) {
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
dispatch('nextTrack')
|
||||
})
|
||||
}
|
||||
if (Playlist.hasPrevious(state)) {
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
dispatch('previousTrack')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (state.playing.isPaused && !mediaElement.paused) {
|
||||
mediaElement.pause()
|
||||
} else if (!state.playing.isPaused && mediaElement.paused) {
|
||||
mediaElement.play()
|
||||
}
|
||||
// When the user clicks or drags on the progress bar, jump to that position
|
||||
if (state.playing.jumpToTime != null) {
|
||||
mediaElement.currentTime = state.playing.jumpToTime
|
||||
state.playing.jumpToTime = null
|
||||
}
|
||||
if (state.playing.playbackRate !== mediaElement.playbackRate) {
|
||||
mediaElement.playbackRate = state.playing.playbackRate
|
||||
}
|
||||
// Recover previous volume
|
||||
if (state.previousVolume !== null && isFinite(state.previousVolume)) {
|
||||
mediaElement.volume = state.previousVolume
|
||||
state.previousVolume = null
|
||||
}
|
||||
|
||||
// Set volume
|
||||
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
|
||||
mediaElement.volume = state.playing.setVolume
|
||||
state.playing.setVolume = null
|
||||
}
|
||||
|
||||
// Switch to the newly added subtitle track, if available
|
||||
const tracks = mediaElement.textTracks || []
|
||||
for (let j = 0; j < tracks.length; j++) {
|
||||
const isSelectedTrack = j === state.playing.subtitles.selectedIndex
|
||||
tracks[j].mode = isSelectedTrack ? 'showing' : 'hidden'
|
||||
}
|
||||
|
||||
// Save video position
|
||||
const file = state.getPlayingFileSummary()
|
||||
file.currentTime = state.playing.currentTime = mediaElement.currentTime
|
||||
file.duration = state.playing.duration = mediaElement.duration
|
||||
|
||||
// Save selected subtitle
|
||||
if (state.playing.subtitles.selectedIndex !== -1) {
|
||||
const index = state.playing.subtitles.selectedIndex
|
||||
file.selectedSubtitle = state.playing.subtitles.tracks[index].filePath
|
||||
} else if (file.selectedSubtitle != null) {
|
||||
delete file.selectedSubtitle
|
||||
}
|
||||
|
||||
// Switch to selected audio track
|
||||
const audioTracks = mediaElement.audioTracks || []
|
||||
for (let j = 0; j < audioTracks.length; j++) {
|
||||
const isSelectedTrack = j === state.playing.audioTracks.selectedIndex
|
||||
audioTracks[j].enabled = isSelectedTrack
|
||||
}
|
||||
|
||||
state.playing.volume = mediaElement.volume
|
||||
}
|
||||
|
||||
// Add subtitles to the <video> tag
|
||||
const trackTags = []
|
||||
if (state.playing.subtitles.selectedIndex >= 0) {
|
||||
state.playing.subtitles.tracks.forEach((track, i) => {
|
||||
const isSelected = state.playing.subtitles.selectedIndex === i
|
||||
trackTags.push(
|
||||
<track
|
||||
key={i}
|
||||
default={isSelected}
|
||||
label={track.label}
|
||||
kind='subtitles'
|
||||
src={track.buffer}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Create the <audio> or <video> tag
|
||||
const MediaTagName = state.playing.type
|
||||
const mediaTag = (
|
||||
<MediaTagName
|
||||
src={Playlist.getCurrentLocalURL(state)}
|
||||
onDoubleClick={dispatcher('toggleFullScreen')}
|
||||
onClick={dispatcher('playPause')}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onEnded={onEnded}
|
||||
onStalled={dispatcher('mediaStalled')}
|
||||
onError={dispatcher('mediaError')}
|
||||
onTimeUpdate={dispatcher('mediaTimeUpdate')}
|
||||
onEncrypted={dispatcher('mediaEncrypted')}
|
||||
>
|
||||
{trackTags}
|
||||
</MediaTagName>
|
||||
)
|
||||
|
||||
// Show the media.
|
||||
return (
|
||||
<div
|
||||
key='letterbox'
|
||||
className='letterbox'
|
||||
onMouseMove={dispatcher('mediaMouseMoved')}
|
||||
>
|
||||
{mediaTag}
|
||||
{renderOverlay(state)}
|
||||
</div>
|
||||
)
|
||||
|
||||
function onLoadedMetadata (e) {
|
||||
const mediaElement = e.target
|
||||
|
||||
// check if we can decode video and audio track
|
||||
if (state.playing.type === 'video') {
|
||||
if (mediaElement.videoTracks.length === 0) {
|
||||
dispatch('mediaError', 'Video codec unsupported')
|
||||
}
|
||||
|
||||
if (mediaElement.audioTracks.length === 0) {
|
||||
dispatch('mediaError', 'Audio codec unsupported')
|
||||
}
|
||||
|
||||
dispatch('mediaSuccess')
|
||||
|
||||
const dimensions = {
|
||||
width: mediaElement.videoWidth,
|
||||
height: mediaElement.videoHeight
|
||||
}
|
||||
|
||||
// As soon as we know the video dimensions, resize the window
|
||||
dispatch('setDimensions', dimensions)
|
||||
|
||||
// set audioTracks
|
||||
const tracks = []
|
||||
for (let i = 0; i < mediaElement.audioTracks.length; i++) {
|
||||
tracks.push({
|
||||
label: mediaElement.audioTracks[i].label || `Track ${i + 1}`,
|
||||
language: mediaElement.audioTracks[i].language
|
||||
})
|
||||
}
|
||||
|
||||
state.playing.audioTracks.tracks = tracks
|
||||
state.playing.audioTracks.selectedIndex = 0
|
||||
}
|
||||
|
||||
// check if we can decode audio track
|
||||
if (state.playing.type === 'audio') {
|
||||
if (mediaElement.audioTracks.length === 0) {
|
||||
dispatch('mediaError', 'Audio codec unsupported')
|
||||
}
|
||||
|
||||
dispatch('mediaSuccess')
|
||||
}
|
||||
}
|
||||
|
||||
function onEnded () {
|
||||
if (Playlist.hasNext(state)) {
|
||||
dispatch('nextTrack')
|
||||
} else {
|
||||
// When the last video completes, pause the video instead of looping
|
||||
state.playing.isPaused = true
|
||||
if (state.window.isFullScreen) dispatch('toggleFullScreen')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverlay (state) {
|
||||
const elems = []
|
||||
const audioMetadataElem = renderAudioMetadata(state)
|
||||
const spinnerElem = renderLoadingSpinner(state)
|
||||
if (audioMetadataElem) elems.push(audioMetadataElem)
|
||||
if (spinnerElem) elems.push(spinnerElem)
|
||||
|
||||
// Video fills the window, centered with black bars if necessary
|
||||
// Audio gets a static poster image and a summary of the file metadata.
|
||||
let style
|
||||
if (state.playing.type === 'audio') {
|
||||
style = { backgroundImage: cssBackgroundImagePoster(state) }
|
||||
} else if (elems.length !== 0) {
|
||||
style = { backgroundImage: cssBackgroundImageDarkGradient() }
|
||||
} else {
|
||||
// Video playing, so no spinner. No overlay needed
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='overlay' className='media-overlay-background' style={style}>
|
||||
<div className='media-overlay'>{elems}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render track or disk number string
|
||||
* @param common metadata.common part
|
||||
* @param key should be either 'track' or 'disk'
|
||||
* @return track or disk number metadata as JSX block
|
||||
*/
|
||||
function renderTrack (common, key) {
|
||||
// Audio metadata: track-number
|
||||
if (common[key] && common[key].no) {
|
||||
let str = `${common[key].no}`
|
||||
if (common[key].of) {
|
||||
str += ` of ${common[key].of}`
|
||||
}
|
||||
const style = { textTransform: 'capitalize' }
|
||||
return (
|
||||
<div className={`audio-${key}`}>
|
||||
<label style={style}>{key}</label> {str}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderAudioMetadata (state) {
|
||||
const fileSummary = state.getPlayingFileSummary()
|
||||
if (!fileSummary.audioInfo) return
|
||||
const common = fileSummary.audioInfo.common || {}
|
||||
|
||||
// Get audio track info
|
||||
const title = common.title ? common.title : fileSummary.name
|
||||
|
||||
// Show a small info box in the middle of the screen with title/album/etc
|
||||
const elems = []
|
||||
|
||||
// Audio metadata: artist(s)
|
||||
const artist = common.artist || common.albumartist
|
||||
if (artist) {
|
||||
elems.push((
|
||||
<div key='artist' className='audio-artist'>
|
||||
<label>Artist</label>{artist}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: disk & track-number
|
||||
const count = ['track', 'disk']
|
||||
count.forEach(key => {
|
||||
const nrElem = renderTrack(common, key)
|
||||
if (nrElem) {
|
||||
elems.push(nrElem)
|
||||
}
|
||||
})
|
||||
|
||||
// Audio metadata: album
|
||||
if (common.album) {
|
||||
elems.push((
|
||||
<div key='album' className='audio-album'>
|
||||
<label>Album</label>{common.album}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: year
|
||||
if (common.year) {
|
||||
elems.push((
|
||||
<div key='year' className='audio-year'>
|
||||
<label>Year</label>{common.year}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: release information (label & catalog-number)
|
||||
if (common.label || common.catalognumber) {
|
||||
const releaseInfo = []
|
||||
if (common.label && common.catalognumber &&
|
||||
common.label.length === common.catalognumber.length) {
|
||||
// Assume labels & catalog-numbers are pairs
|
||||
for (let n = 0; n < common.label.length; ++n) {
|
||||
releaseInfo.push(common.label[0] + ' / ' + common.catalognumber[n])
|
||||
}
|
||||
} else {
|
||||
if (common.label) {
|
||||
releaseInfo.push(...common.label)
|
||||
}
|
||||
if (common.catalognumber) {
|
||||
releaseInfo.push(...common.catalognumber)
|
||||
}
|
||||
}
|
||||
elems.push((
|
||||
<div key='release' className='audio-release'>
|
||||
<label>Release</label>{releaseInfo.join(', ')}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: format
|
||||
const format = []
|
||||
fileSummary.audioInfo.format = fileSummary.audioInfo.format || ''
|
||||
if (fileSummary.audioInfo.format.container) {
|
||||
format.push(fileSummary.audioInfo.format.container)
|
||||
}
|
||||
if (fileSummary.audioInfo.format.codec &&
|
||||
fileSummary.audioInfo.format.container !== fileSummary.audioInfo.format.codec) {
|
||||
format.push(fileSummary.audioInfo.format.codec)
|
||||
}
|
||||
if (fileSummary.audioInfo.format.bitrate) {
|
||||
format.push(Math.round(fileSummary.audioInfo.format.bitrate / 1000) + ' kbit/s') // 128 kbit/s
|
||||
}
|
||||
if (fileSummary.audioInfo.format.sampleRate) {
|
||||
format.push(Math.round(fileSummary.audioInfo.format.sampleRate / 100) / 10 + ' kHz')
|
||||
}
|
||||
if (fileSummary.audioInfo.format.bitsPerSample) {
|
||||
format.push(fileSummary.audioInfo.format.bitsPerSample + '-bit')
|
||||
}
|
||||
if (format.length > 0) {
|
||||
elems.push((
|
||||
<div key='format' className='audio-format'>
|
||||
<label>Format</label>{format.join(', ')}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: comments
|
||||
if (common.comment) {
|
||||
elems.push((
|
||||
<div key='comments' className='audio-comments'>
|
||||
<label>Comments</label>{common.comment.join(' / ')}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Align the title with the other info, if available. Otherwise, center title
|
||||
const emptyLabel = (<label />)
|
||||
elems.unshift((
|
||||
<div key='title' className='audio-title'>
|
||||
{elems.length ? emptyLabel : undefined}{title}
|
||||
</div>
|
||||
))
|
||||
|
||||
return (<div key='audio-metadata' className='audio-metadata'>{elems}</div>)
|
||||
}
|
||||
|
||||
function renderLoadingSpinner (state) {
|
||||
if (state.playing.isPaused) return
|
||||
const isProbablyStalled = state.playing.isStalled ||
|
||||
(new Date().getTime() - state.playing.lastTimeUpdate > 2000)
|
||||
if (!isProbablyStalled) return
|
||||
|
||||
const prog = state.getPlayingTorrentSummary().progress || {}
|
||||
let fileProgress = 0
|
||||
if (prog.files) {
|
||||
const file = prog.files[state.playing.fileIndex]
|
||||
fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='loading' className='media-stalled'>
|
||||
<div key='loading-spinner' className='loading-spinner' />
|
||||
<div key='loading-progress' className='loading-status ellipsis'>
|
||||
<span><span className='progress'>{fileProgress}%</span> downloaded</span>
|
||||
<span> ↓ {prettyBytes(prog.downloadSpeed || 0)}/s</span>
|
||||
<span> ↑ {prettyBytes(prog.uploadSpeed || 0)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCastScreen (state) {
|
||||
let castIcon, castType, isCast
|
||||
if (state.playing.location.startsWith('chromecast')) {
|
||||
castIcon = 'cast_connected'
|
||||
castType = 'Chromecast'
|
||||
isCast = true
|
||||
} else if (state.playing.location.startsWith('airplay')) {
|
||||
castIcon = 'airplay'
|
||||
castType = 'AirPlay'
|
||||
isCast = true
|
||||
} else if (state.playing.location.startsWith('dlna')) {
|
||||
castIcon = 'tv'
|
||||
castType = 'DLNA'
|
||||
isCast = true
|
||||
} else if (state.playing.location === 'external') {
|
||||
castIcon = 'tv'
|
||||
castType = state.getExternalPlayerName()
|
||||
isCast = false
|
||||
} else if (state.playing.location === 'error') {
|
||||
castIcon = 'error_outline'
|
||||
castType = 'Unable to Play'
|
||||
isCast = false
|
||||
}
|
||||
|
||||
const isStarting = state.playing.location.endsWith('-pending')
|
||||
const castName = state.playing.castName
|
||||
const fileName = state.getPlayingFileSummary().name || ''
|
||||
let castStatus
|
||||
if (isCast && isStarting) castStatus = 'Connecting to ' + castName + '...'
|
||||
else if (isCast && !isStarting) castStatus = 'Connected to ' + castName
|
||||
else castStatus = ''
|
||||
|
||||
const prog = state.getPlayingTorrentSummary().progress || {}
|
||||
|
||||
// Show a nice title image, if possible
|
||||
const style = {
|
||||
backgroundImage: cssBackgroundImagePoster(state)
|
||||
}
|
||||
|
||||
function renderEta (total, downloaded) {
|
||||
const missing = (total || 0) - (downloaded || 0)
|
||||
const downloadSpeed = prog.downloadSpeed || 0
|
||||
if (downloadSpeed === 0 || missing === 0) return
|
||||
|
||||
const etaStr = calculateEta(missing, downloadSpeed)
|
||||
|
||||
return (<span>{etaStr}</span>)
|
||||
}
|
||||
|
||||
function renderDownloadProgress () {
|
||||
if (!prog.files) return
|
||||
|
||||
const fileProg = prog.files[state.playing.fileIndex]
|
||||
const fileProgress = fileProg.numPiecesPresent / fileProg.numPieces
|
||||
const fileLength = state.getPlayingFileSummary().length
|
||||
const fileDownloaded = fileProgress * fileLength
|
||||
|
||||
const progress = Math.round(100 * fileProgress)
|
||||
const total = prettyBytes(fileLength)
|
||||
const completed = prettyBytes(fileDownloaded)
|
||||
|
||||
const downloadSpeed = prettyBytes(prog.downloadSpeed || 0)
|
||||
const uploadSpeed = prettyBytes(prog.uploadSpeed || 0)
|
||||
|
||||
let sizes
|
||||
if (fileProgress < 1) {
|
||||
sizes = <span> | {completed} / {total}</span>
|
||||
} else {
|
||||
sizes = <span> | {completed}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='download-progress'>
|
||||
<span className='progress'>{progress}% downloaded {sizes}</span>
|
||||
<br />
|
||||
<span>↓ {downloadSpeed}/s ↑ {uploadSpeed}/s | {prog.numPeers || 0} peer(s)</span>
|
||||
<br />
|
||||
{renderEta(fileLength, fileDownloaded)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 key='name' className='name'>{fileName}</div>
|
||||
{renderDownloadProgress()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCastOptions (state) {
|
||||
if (!state.devices.castMenu) return
|
||||
|
||||
const { location, devices } = state.devices.castMenu
|
||||
const player = state.devices[location]
|
||||
|
||||
const items = devices.map((device, ix) => {
|
||||
const isSelected = player.device === device
|
||||
const name = device.name
|
||||
return (
|
||||
<li key={ix} onClick={dispatcher('selectCastDevice', ix)}>
|
||||
<i className='icon'>{isSelected ? 'radio_button_checked' : 'radio_button_unchecked'}</i>
|
||||
{' '}
|
||||
{name}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<ul key='cast-options' className='options-list'>
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSubtitleOptions (state) {
|
||||
const subtitles = state.playing.subtitles
|
||||
if (!subtitles.tracks.length || !subtitles.showMenu) return
|
||||
|
||||
const items = subtitles.tracks.map((track, ix) => {
|
||||
const isSelected = state.playing.subtitles.selectedIndex === ix
|
||||
return (
|
||||
<li key={ix} onClick={dispatcher('selectSubtitle', ix)}>
|
||||
<i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
|
||||
{track.label}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
const noneSelected = state.playing.subtitles.selectedIndex === -1
|
||||
const noneClass = 'radio_button_' + (noneSelected ? 'checked' : 'unchecked')
|
||||
return (
|
||||
<ul key='subtitle-options' className='options-list'>
|
||||
{items}
|
||||
<li onClick={dispatcher('selectSubtitle', -1)}>
|
||||
<i className='icon'>{noneClass}</i>
|
||||
None
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAudioTrackOptions (state) {
|
||||
const audioTracks = state.playing.audioTracks
|
||||
if (!audioTracks.tracks.length || !audioTracks.showMenu) return
|
||||
|
||||
const items = audioTracks.tracks.map((track, ix) => {
|
||||
const isSelected = state.playing.audioTracks.selectedIndex === ix
|
||||
return (
|
||||
<li key={ix} onClick={dispatcher('selectAudioTrack', ix)}>
|
||||
<i className='icon'>{'radio_button_' + (isSelected ? 'checked' : 'unchecked')}</i>
|
||||
{track.label}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<ul key='audio-track-options' className='options-list'>
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPlayerControls (state) {
|
||||
const positionPercent = 100 * state.playing.currentTime / state.playing.duration
|
||||
const playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
|
||||
const captionsClass = state.playing.subtitles.tracks.length === 0
|
||||
? 'disabled'
|
||||
: state.playing.subtitles.selectedIndex >= 0
|
||||
? 'active'
|
||||
: ''
|
||||
const multiAudioClass = state.playing.audioTracks.tracks.length > 1
|
||||
? 'active'
|
||||
: 'disabled'
|
||||
const prevClass = Playlist.hasPrevious(state) ? '' : 'disabled'
|
||||
const nextClass = Playlist.hasNext(state) ? '' : 'disabled'
|
||||
|
||||
const elements = [
|
||||
renderPreview(state),
|
||||
|
||||
<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'
|
||||
onMouseMove={handleScrubPreview}
|
||||
onMouseOut={clearPreview}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleScrub}
|
||||
onDrag={handleScrub}
|
||||
/>
|
||||
</div>,
|
||||
|
||||
<i
|
||||
key='skip-previous'
|
||||
className={'icon skip-previous float-left ' + prevClass}
|
||||
onClick={dispatcher('previousTrack')}
|
||||
role='button'
|
||||
aria-label='Previous track'
|
||||
>
|
||||
skip_previous
|
||||
</i>,
|
||||
|
||||
<i
|
||||
key='play'
|
||||
className='icon play-pause float-left'
|
||||
onClick={dispatcher('playPause')}
|
||||
role='button'
|
||||
aria-label={state.playing.isPaused ? 'Play' : 'Pause'}
|
||||
>
|
||||
{state.playing.isPaused ? 'play_arrow' : 'pause'}
|
||||
</i>,
|
||||
|
||||
<i
|
||||
key='skip-next'
|
||||
className={'icon skip-next float-left ' + nextClass}
|
||||
onClick={dispatcher('nextTrack')}
|
||||
role='button'
|
||||
aria-label='Next track'
|
||||
>
|
||||
skip_next
|
||||
</i>,
|
||||
|
||||
<i
|
||||
key='fullscreen'
|
||||
className='icon fullscreen float-right'
|
||||
onClick={dispatcher('toggleFullScreen')}
|
||||
role='button'
|
||||
aria-label={state.window.isFullScreen ? 'Exit full screen' : 'Enter full screen'}
|
||||
>
|
||||
{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}
|
||||
role='button'
|
||||
aria-label='Closed captions'
|
||||
>
|
||||
closed_caption
|
||||
</i>
|
||||
), (
|
||||
<i
|
||||
key='audio-tracks'
|
||||
className={'icon multi-audio float-right ' + multiAudioClass}
|
||||
onClick={handleAudioTracks}
|
||||
>
|
||||
library_music
|
||||
</i>
|
||||
))
|
||||
}
|
||||
|
||||
// If we've detected a Chromecast or AppleTV, the user can play video there
|
||||
const castTypes = ['chromecast', 'airplay', 'dlna']
|
||||
const isCastingAnywhere = castTypes.some(
|
||||
(castType) => state.playing.location.startsWith(castType))
|
||||
|
||||
// Add the cast buttons. Icons for each cast type, connected/disconnected:
|
||||
const buttonIcons = {
|
||||
chromecast: { true: 'cast_connected', false: 'cast' },
|
||||
airplay: { true: 'airplay', false: 'airplay' },
|
||||
dlna: { true: 'tv', false: 'tv' }
|
||||
}
|
||||
castTypes.forEach(castType => {
|
||||
// Do we show this button (eg. the Chromecast button) at all?
|
||||
const isCasting = state.playing.location.startsWith(castType)
|
||||
const player = state.devices[castType]
|
||||
if ((!player || player.getDevices().length === 0) && !isCasting) return
|
||||
|
||||
// Show the button. Three options for eg the Chromecast button:
|
||||
let buttonClass, buttonHandler
|
||||
if (isCasting) {
|
||||
// Option 1: we are currently connected to Chromecast. Button stops the cast.
|
||||
buttonClass = 'active'
|
||||
buttonHandler = dispatcher('stopCasting')
|
||||
} else if (isCastingAnywhere) {
|
||||
// Option 2: we are currently connected somewhere else. Button disabled.
|
||||
buttonClass = 'disabled'
|
||||
buttonHandler = undefined
|
||||
} else {
|
||||
// Option 3: we are not connected anywhere. Button opens Chromecast menu.
|
||||
buttonClass = ''
|
||||
buttonHandler = dispatcher('toggleCastMenu', castType)
|
||||
}
|
||||
const buttonIcon = buttonIcons[castType][isCasting]
|
||||
|
||||
elements.push((
|
||||
<i
|
||||
key={castType}
|
||||
className={'icon device float-right ' + buttonClass}
|
||||
onClick={buttonHandler}
|
||||
>
|
||||
{buttonIcon}
|
||||
</i>
|
||||
))
|
||||
})
|
||||
|
||||
// Render volume slider
|
||||
const volume = state.playing.volume
|
||||
const volumeIcon = 'volume_' + (
|
||||
volume === 0
|
||||
? 'off'
|
||||
: volume < 0.3
|
||||
? 'mute'
|
||||
: volume < 0.6
|
||||
? 'down'
|
||||
: 'up'
|
||||
)
|
||||
const volumeStyle = {
|
||||
background: '-webkit-gradient(linear, left top, right top, ' +
|
||||
'color-stop(' + (volume * 100) + '%, #eee), ' +
|
||||
'color-stop(' + (volume * 100) + '%, #727272))'
|
||||
}
|
||||
|
||||
elements.push((
|
||||
<div key='volume' className='volume float-left'>
|
||||
<i
|
||||
className='icon volume-icon float-left'
|
||||
onMouseDown={handleVolumeMute}
|
||||
role='button'
|
||||
aria-label='Mute'
|
||||
>
|
||||
{volumeIcon}
|
||||
</i>
|
||||
<input
|
||||
className='volume-slider float-right'
|
||||
type='range' min='0' max='1' step='0.05'
|
||||
value={volume}
|
||||
onChange={handleVolumeScrub}
|
||||
style={volumeStyle}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
// Show video playback progress
|
||||
const currentTimeStr = formatTime(state.playing.currentTime, state.playing.duration)
|
||||
const durationStr = formatTime(state.playing.duration, 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>
|
||||
))
|
||||
}
|
||||
|
||||
const emptyImage = new window.Image(0, 0)
|
||||
emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs%3D'
|
||||
function handleDragStart (e) {
|
||||
if (e.dataTransfer) {
|
||||
const dt = e.dataTransfer
|
||||
// Prevent the cursor from changing, eg to a green + icon on Mac
|
||||
dt.effectAllowed = 'none'
|
||||
// Prevent ghost image
|
||||
dt.setDragImage(emptyImage, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Handles a scrub hover (preview another position in the video)
|
||||
function handleScrubPreview (e) {
|
||||
// Only show for videos
|
||||
if (!e.clientX || state.playing.type !== 'video') return
|
||||
dispatch('mediaMouseMoved')
|
||||
dispatch('preview', e.clientX)
|
||||
}
|
||||
|
||||
function clearPreview () {
|
||||
if (state.playing.type !== 'video') return
|
||||
dispatch('clearPreview')
|
||||
}
|
||||
|
||||
// Handles a click or drag to scrub (jump to another position in the video)
|
||||
function handleScrub (e) {
|
||||
if (!e.clientX) return
|
||||
dispatch('mediaMouseMoved')
|
||||
const windowWidth = document.querySelector('body').clientWidth
|
||||
const fraction = e.clientX / windowWidth
|
||||
const position = fraction * state.playing.duration /* seconds */
|
||||
dispatch('skipTo', position)
|
||||
}
|
||||
|
||||
// Handles volume muting and Unmuting
|
||||
function handleVolumeMute () {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioTracks () {
|
||||
dispatch('toggleAudioTracksMenu')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key='controls' className='controls'
|
||||
onMouseEnter={dispatcher('mediaControlsMouseEnter')}
|
||||
onMouseLeave={dispatcher('mediaControlsMouseLeave')}
|
||||
>
|
||||
{elements}
|
||||
{renderCastOptions(state)}
|
||||
{renderSubtitleOptions(state)}
|
||||
{renderAudioTrackOptions(state)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreview (state) {
|
||||
const { previewXCoord = null } = state.playing
|
||||
|
||||
// Calculate time from x-coord as fraction of track width
|
||||
const windowWidth = document.querySelector('body').clientWidth
|
||||
const fraction = previewXCoord / windowWidth
|
||||
const time = fraction * state.playing.duration /* seconds */
|
||||
|
||||
const height = 70
|
||||
let width = 0
|
||||
|
||||
const previewEl = document.querySelector('video#preview')
|
||||
if (previewEl !== null && previewXCoord !== null) {
|
||||
previewEl.currentTime = time
|
||||
|
||||
// Auto adjust width to maintain video aspect ratio
|
||||
width = Math.floor((previewEl.videoWidth / previewEl.videoHeight) * height)
|
||||
}
|
||||
|
||||
// Center preview window on mouse cursor,
|
||||
// while avoiding falling off the left or right edges
|
||||
const xPos = Math.min(Math.max(previewXCoord - (width / 2), 5), windowWidth - width - 5)
|
||||
|
||||
return (
|
||||
<div
|
||||
key='preview' style={{
|
||||
position: 'absolute',
|
||||
bottom: 50,
|
||||
left: xPos,
|
||||
display: previewXCoord == null && 'none' // Hide preview when XCoord unset
|
||||
}}
|
||||
>
|
||||
<div style={{ width, height, backgroundColor: 'black' }}>
|
||||
<video
|
||||
src={Playlist.getCurrentLocalURL(state)}
|
||||
id='preview'
|
||||
style={{ border: '1px solid lightgrey', borderRadius: 2 }}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center', margin: 5, textShadow: '0 0 2px rgba(0,0,0,.5)', color: '#eee'
|
||||
}}
|
||||
>
|
||||
{formatTime(time, state.playing.duration)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renders the loading bar. Shows which parts of the torrent are loaded, which
|
||||
// can be 'spongey' / non-contiguous
|
||||
function renderLoadingBar (state) {
|
||||
if (config.IS_TEST) return // Don't integration test the loading bar. Screenshots won't match.
|
||||
|
||||
const torrentSummary = state.getPlayingTorrentSummary()
|
||||
if (!torrentSummary.progress) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find all contiguous parts of the torrent which are loaded
|
||||
const prog = torrentSummary.progress
|
||||
const fileProg = prog.files[state.playing.fileIndex]
|
||||
|
||||
if (!fileProg) return null
|
||||
|
||||
const parts = []
|
||||
let lastPiecePresent = false
|
||||
for (let i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
|
||||
const partPresent = BitField.prototype.get.call(prog.bitfield, i)
|
||||
if (partPresent && !lastPiecePresent) {
|
||||
parts.push({ start: i - fileProg.startPiece, count: 1 })
|
||||
} else if (partPresent) {
|
||||
parts[parts.length - 1].count++
|
||||
}
|
||||
lastPiecePresent = partPresent
|
||||
}
|
||||
|
||||
// Output some bars to show which parts of the file are loaded
|
||||
const loadingBarElems = parts.map((part, i) => {
|
||||
const style = {
|
||||
left: (100 * part.start / fileProg.numPieces) + '%',
|
||||
width: (100 * part.count / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
return (<div key={i} className='loading-bar-part' style={style} />)
|
||||
})
|
||||
|
||||
return (<div key='loading-bar' className='loading-bar'>{loadingBarElems}</div>)
|
||||
}
|
||||
|
||||
// Returns the CSS background-image string for a poster image + dark vignette
|
||||
function cssBackgroundImagePoster (state) {
|
||||
const torrentSummary = state.getPlayingTorrentSummary()
|
||||
const posterPath = TorrentSummary.getPosterPath(torrentSummary)
|
||||
if (!posterPath) return ''
|
||||
return cssBackgroundImageDarkGradient() + `, url('${posterPath}')`
|
||||
}
|
||||
|
||||
function cssBackgroundImageDarkGradient () {
|
||||
return 'radial-gradient(circle at center, ' +
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
|
||||
}
|
||||
|
||||
function formatTime (time, total) {
|
||||
if (typeof time !== 'number' || Number.isNaN(time)) {
|
||||
return '0:00'
|
||||
}
|
||||
|
||||
const totalHours = Math.floor(total / 3600)
|
||||
const totalMinutes = Math.floor(total / 60)
|
||||
const hours = Math.floor(time / 3600)
|
||||
let minutes = Math.floor(time % 3600 / 60)
|
||||
if (totalMinutes > 9 && minutes < 10) {
|
||||
minutes = '0' + minutes
|
||||
}
|
||||
const seconds = `0${Math.floor(time % 60)}`.slice(-2)
|
||||
|
||||
return (totalHours > 0 ? hours + ':' : '') + minutes + ':' + seconds
|
||||
}
|
336
src/renderer/pages/preferences-page.js
Normal file
@ -0,0 +1,336 @@
|
||||
const React = require('react')
|
||||
const PropTypes = require('prop-types')
|
||||
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const Checkbox = require('material-ui/Checkbox').default
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
const TextField = require('material-ui/TextField').default
|
||||
const Heading = require('../components/heading')
|
||||
const PathSelector = require('../components/path-selector')
|
||||
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
const config = require('../../config')
|
||||
|
||||
class PreferencesPage extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.handleDownloadPathChange =
|
||||
this.handleDownloadPathChange.bind(this)
|
||||
|
||||
this.handleOpenExternalPlayerChange =
|
||||
this.handleOpenExternalPlayerChange.bind(this)
|
||||
|
||||
this.handleExternalPlayerPathChange =
|
||||
this.handleExternalPlayerPathChange.bind(this)
|
||||
|
||||
this.handleStartupChange =
|
||||
this.handleStartupChange.bind(this)
|
||||
|
||||
this.handleSoundNotificationsChange =
|
||||
this.handleSoundNotificationsChange.bind(this)
|
||||
|
||||
this.handleSetGlobalTrackers =
|
||||
this.handleSetGlobalTrackers.bind(this)
|
||||
|
||||
const globalTrackers = this.props.state.getGlobalTrackers().join('\n')
|
||||
|
||||
this.state = {
|
||||
globalTrackers
|
||||
}
|
||||
}
|
||||
|
||||
downloadPathSelector () {
|
||||
return (
|
||||
<Preference>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select download directory',
|
||||
properties: ['openDirectory']
|
||||
}}
|
||||
onChange={this.handleDownloadPathChange}
|
||||
title='Download location'
|
||||
value={this.props.state.saved.prefs.downloadPath}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleDownloadPathChange (filePath) {
|
||||
dispatch('updatePreferences', 'downloadPath', filePath)
|
||||
}
|
||||
|
||||
openExternalPlayerCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={!this.props.state.saved.prefs.openExternalPlayer}
|
||||
label='Play torrent media files using LeenkxBox'
|
||||
onCheck={this.handleOpenExternalPlayerChange}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleOpenExternalPlayerChange (e, isChecked) {
|
||||
dispatch('updatePreferences', 'openExternalPlayer', !isChecked)
|
||||
}
|
||||
|
||||
highestPlaybackPriorityCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.saved.prefs.highestPlaybackPriority}
|
||||
label='Highest Playback Priority'
|
||||
onCheck={this.handleHighestPlaybackPriorityChange}
|
||||
/>
|
||||
<p>Pauses all active torrents to allow playback to use all of the available bandwidth.</p>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleHighestPlaybackPriorityChange (e, isChecked) {
|
||||
dispatch('updatePreferences', 'highestPlaybackPriority', isChecked)
|
||||
}
|
||||
|
||||
externalPlayerPathSelector () {
|
||||
const playerPath = this.props.state.saved.prefs.externalPlayerPath
|
||||
const playerName = this.props.state.getExternalPlayerName()
|
||||
|
||||
const description = this.props.state.saved.prefs.openExternalPlayer
|
||||
? `Torrent media files will always play in ${playerName}.`
|
||||
: `Torrent media files will play in ${playerName} if LeenkxBox cannot play them.`
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<p>{description}</p>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select media player app',
|
||||
properties: ['openFile']
|
||||
}}
|
||||
onChange={this.handleExternalPlayerPathChange}
|
||||
title='External player'
|
||||
value={playerPath}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleExternalPlayerPathChange (filePath) {
|
||||
dispatch('updatePreferences', 'externalPlayerPath', filePath)
|
||||
}
|
||||
|
||||
autoAddTorrentsCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.saved.prefs.autoAddTorrents}
|
||||
label='Watch for new .torrent files and add them immediately'
|
||||
onCheck={(e, value) => { this.handleAutoAddTorrentsChange(e, value) }}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleAutoAddTorrentsChange (e, isChecked) {
|
||||
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
|
||||
if (isChecked && !torrentsFolderPath) {
|
||||
alert('Select a torrents folder first.') // eslint-disable-line
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
dispatch('updatePreferences', 'autoAddTorrents', isChecked)
|
||||
|
||||
if (isChecked) {
|
||||
dispatch('startFolderWatcher')
|
||||
return
|
||||
}
|
||||
|
||||
dispatch('stopFolderWatcher')
|
||||
}
|
||||
|
||||
torrentsFolderPathSelector () {
|
||||
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select folder to watch for new torrents',
|
||||
properties: ['openDirectory']
|
||||
}}
|
||||
onChange={this.handleTorrentsFolderPathChange}
|
||||
title='Folder to watch'
|
||||
value={torrentsFolderPath}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleTorrentsFolderPathChange (filePath) {
|
||||
dispatch('updatePreferences', 'torrentsFolderPath', filePath)
|
||||
}
|
||||
|
||||
setDefaultAppButton () {
|
||||
const isFileHandler = this.props.state.saved.prefs.isFileHandler
|
||||
if (isFileHandler) {
|
||||
return (
|
||||
<Preference>
|
||||
<p>LeenkxBox is your default torrent app. Hooray!</p>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Preference>
|
||||
<p>LeenkxBox is not currently the default torrent app.</p>
|
||||
<RaisedButton
|
||||
className='control'
|
||||
onClick={this.handleSetDefaultApp}
|
||||
label='Make LeenkxBox the default'
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleStartupChange (e, isChecked) {
|
||||
dispatch('updatePreferences', 'startup', isChecked)
|
||||
}
|
||||
|
||||
setStartupCheckbox () {
|
||||
if (config.IS_PORTABLE) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.saved.prefs.startup}
|
||||
label='Open LeenkxBox on startup'
|
||||
onCheck={this.handleStartupChange}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
soundNotificationsCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.saved.prefs.soundNotifications}
|
||||
label='Enable sounds'
|
||||
onCheck={this.handleSoundNotificationsChange}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleSoundNotificationsChange (e, isChecked) {
|
||||
dispatch('updatePreferences', 'soundNotifications', isChecked)
|
||||
}
|
||||
|
||||
handleSetDefaultApp () {
|
||||
dispatch('updatePreferences', 'isFileHandler', true)
|
||||
}
|
||||
|
||||
setGlobalTrackers () {
|
||||
// Align the text fields
|
||||
const textFieldStyle = { width: '100%' }
|
||||
const textareaStyle = { margin: 0 }
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<TextField
|
||||
className='torrent-trackers control'
|
||||
style={textFieldStyle}
|
||||
textareaStyle={textareaStyle}
|
||||
multiLine
|
||||
rows={2}
|
||||
rowsMax={10}
|
||||
value={this.state.globalTrackers}
|
||||
onChange={this.handleSetGlobalTrackers}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleSetGlobalTrackers (e, globalTrackers) {
|
||||
this.setState({ globalTrackers })
|
||||
|
||||
const announceList = globalTrackers
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
|
||||
dispatch('updatePreferences', 'globalTrackers', announceList)
|
||||
dispatch('updateGlobalTrackers', announceList)
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
color: colors.grey400,
|
||||
marginLeft: 25,
|
||||
marginRight: 25
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
<PreferencesSection title='Folders'>
|
||||
{this.downloadPathSelector()}
|
||||
{this.autoAddTorrentsCheckbox()}
|
||||
{this.torrentsFolderPathSelector()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Playback'>
|
||||
{this.openExternalPlayerCheckbox()}
|
||||
{this.externalPlayerPathSelector()}
|
||||
{this.highestPlaybackPriorityCheckbox()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Default torrent app'>
|
||||
{this.setDefaultAppButton()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='General'>
|
||||
{this.setStartupCheckbox()}
|
||||
{this.soundNotificationsCheckbox()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Trackers'>
|
||||
{this.setGlobalTrackers()}
|
||||
</PreferencesSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PreferencesSection extends React.Component {
|
||||
static get propTypes () {
|
||||
return {
|
||||
title: PropTypes.string
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
marginBottom: 25,
|
||||
marginTop: 25
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
<Heading level={2}>{this.props.title}</Heading>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Preference extends React.Component {
|
||||
render () {
|
||||
const style = { marginBottom: 10 }
|
||||
return (<div style={style}>{this.props.children}</div>)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PreferencesPage
|
414
src/renderer/pages/torrent-list-page.js
Normal file
@ -0,0 +1,414 @@
|
||||
const React = require('react')
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
|
||||
const Checkbox = require('material-ui/Checkbox').default
|
||||
const LinearProgress = require('material-ui/LinearProgress').default
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const TorrentPlayer = require('../lib/torrent-player')
|
||||
const { dispatcher } = require('../lib/dispatcher')
|
||||
const { calculateEta } = require('../lib/time')
|
||||
|
||||
module.exports = class TorrentList extends React.Component {
|
||||
render () {
|
||||
const state = this.props.state
|
||||
|
||||
const contents = []
|
||||
if (state.downloadPathStatus === 'missing') {
|
||||
contents.push(
|
||||
<div key='torrent-missing-path'>
|
||||
<p>Download path missing: {state.saved.prefs.downloadPath}</p>
|
||||
<p>Check that all drives are connected?</p>
|
||||
<p>Alternatively, choose a new download path
|
||||
in <a href='#' onClick={dispatcher('preferences')}>Preferences</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const torrentElems = state.saved.torrents.map(
|
||||
(torrentSummary) => this.renderTorrent(torrentSummary)
|
||||
)
|
||||
contents.push(...torrentElems)
|
||||
contents.push(
|
||||
<div key='torrent-placeholder' className='torrent-placeholder'>
|
||||
<span className='ellipsis'>Drop a torrent file here or paste a magnet link</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key='torrent-list'
|
||||
className='torrent-list'
|
||||
onContextMenu={dispatcher('openTorrentListContextMenu')}
|
||||
>
|
||||
{contents}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderTorrent (torrentSummary) {
|
||||
const state = this.props.state
|
||||
const infoHash = torrentSummary.infoHash
|
||||
const isSelected = infoHash && state.selectedInfoHash === infoHash
|
||||
|
||||
// Background image: show some nice visuals, like a frame from the movie, if possible
|
||||
const style = {}
|
||||
if (torrentSummary.posterFileName) {
|
||||
const gradient = 'linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.4) 100%)'
|
||||
const posterPath = TorrentSummary.getPosterPath(torrentSummary)
|
||||
style.backgroundImage = `${gradient}, url('${posterPath}')`
|
||||
}
|
||||
|
||||
// Foreground: name of the torrent, basic info like size, play button,
|
||||
// cast buttons if available, and delete
|
||||
const classes = ['torrent']
|
||||
if (isSelected) classes.push('selected')
|
||||
if (!infoHash) classes.push('disabled')
|
||||
if (!torrentSummary.torrentKey) throw new Error('Missing torrentKey')
|
||||
return (
|
||||
<div
|
||||
id={torrentSummary.testID && ('torrent-' + torrentSummary.testID)}
|
||||
key={torrentSummary.torrentKey}
|
||||
style={style}
|
||||
className={classes.join(' ')}
|
||||
onContextMenu={infoHash && dispatcher('openTorrentContextMenu', infoHash)}
|
||||
onClick={infoHash && dispatcher('toggleSelectTorrent', infoHash)}
|
||||
>
|
||||
{this.renderTorrentMetadata(torrentSummary)}
|
||||
{infoHash ? this.renderTorrentButtons(torrentSummary) : null}
|
||||
{isSelected ? this.renderTorrentDetails(torrentSummary) : null}
|
||||
<hr />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show name, download status, % complete
|
||||
renderTorrentMetadata (torrentSummary) {
|
||||
const name = torrentSummary.name || 'Loading torrent...'
|
||||
const elements = [(
|
||||
<div key='name' className='name ellipsis'>{name}</div>
|
||||
)]
|
||||
|
||||
// If it's downloading/seeding then show progress info
|
||||
const prog = torrentSummary.progress
|
||||
let progElems
|
||||
if (torrentSummary.error) {
|
||||
progElems = [getErrorMessage(torrentSummary)]
|
||||
} else if (torrentSummary.status !== 'paused' && prog) {
|
||||
progElems = [
|
||||
renderDownloadCheckbox(),
|
||||
renderTorrentStatus(),
|
||||
renderProgressBar(),
|
||||
renderPercentProgress(),
|
||||
renderTotalProgress(),
|
||||
renderPeers(),
|
||||
renderSpeeds(),
|
||||
renderEta()
|
||||
]
|
||||
} else {
|
||||
progElems = [
|
||||
renderDownloadCheckbox(),
|
||||
renderTorrentStatus()
|
||||
]
|
||||
}
|
||||
elements.push(
|
||||
<div key='progress-info' className='ellipsis'>
|
||||
{progElems}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (<div key='metadata' className='metadata'>{elements}</div>)
|
||||
|
||||
function renderDownloadCheckbox () {
|
||||
const infoHash = torrentSummary.infoHash
|
||||
const isActive = ['downloading', 'seeding'].includes(torrentSummary.status)
|
||||
return (
|
||||
<Checkbox
|
||||
key='download-button'
|
||||
className={'control download ' + torrentSummary.status}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 32
|
||||
}}
|
||||
iconStyle={{
|
||||
width: 20,
|
||||
height: 20
|
||||
}}
|
||||
checked={isActive}
|
||||
onClick={stopPropagation}
|
||||
onCheck={dispatcher('toggleTorrent', infoHash)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderProgressBar () {
|
||||
const progress = Math.floor(100 * prog.progress)
|
||||
const styles = {
|
||||
wrapper: {
|
||||
display: 'inline-block',
|
||||
marginRight: 8
|
||||
},
|
||||
progress: {
|
||||
height: 8,
|
||||
width: 30
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key='progress-bar' style={styles.wrapper}>
|
||||
<LinearProgress style={styles.progress} mode='determinate' value={progress} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPercentProgress () {
|
||||
const progress = Math.floor(100 * prog.progress)
|
||||
return (<span key='percent-progress'>{progress}%</span>)
|
||||
}
|
||||
|
||||
function renderTotalProgress () {
|
||||
const downloaded = prettyBytes(prog.downloaded)
|
||||
const total = prettyBytes(prog.length || 0)
|
||||
if (downloaded === total) {
|
||||
return (<span key='total-progress'>{downloaded}</span>)
|
||||
} else {
|
||||
return (<span key='total-progress'>{downloaded} / {total}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeers () {
|
||||
if (prog.numPeers === 0) return
|
||||
const count = prog.numPeers === 1 ? 'peer' : 'peers'
|
||||
return (<span key='peers'>{prog.numPeers} {count}</span>)
|
||||
}
|
||||
|
||||
function renderSpeeds () {
|
||||
let str = ''
|
||||
if (prog.downloadSpeed > 0) str += ' ↓ ' + prettyBytes(prog.downloadSpeed) + '/s'
|
||||
if (prog.uploadSpeed > 0) str += ' ↑ ' + prettyBytes(prog.uploadSpeed) + '/s'
|
||||
if (str === '') return
|
||||
return (<span key='download'>{str}</span>)
|
||||
}
|
||||
|
||||
function renderEta () {
|
||||
const downloaded = prog.downloaded
|
||||
const total = prog.length || 0
|
||||
const missing = total - downloaded
|
||||
const downloadSpeed = prog.downloadSpeed
|
||||
if (downloadSpeed === 0 || missing === 0) return
|
||||
|
||||
const etaStr = calculateEta(missing, downloadSpeed)
|
||||
|
||||
return (<span key='eta'>{etaStr}</span>)
|
||||
}
|
||||
|
||||
function renderTorrentStatus () {
|
||||
let status
|
||||
if (torrentSummary.status === 'paused') {
|
||||
if (!torrentSummary.progress) status = ''
|
||||
else if (torrentSummary.progress.progress === 1) status = 'Not seeding'
|
||||
else status = 'Paused'
|
||||
} else if (torrentSummary.status === 'downloading') {
|
||||
if (!torrentSummary.progress) status = ''
|
||||
else if (!torrentSummary.progress.ready) status = 'Verifying'
|
||||
else status = 'Downloading'
|
||||
} else if (torrentSummary.status === 'seeding') {
|
||||
status = 'Seeding'
|
||||
} else { // torrentSummary.status is 'new' or something unexpected
|
||||
status = ''
|
||||
}
|
||||
return (<span key='torrent-status'>{status}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
// Download button toggles between torrenting (DL/seed) and paused
|
||||
// Play button starts streaming the torrent immediately, unpausing if needed
|
||||
renderTorrentButtons (torrentSummary) {
|
||||
const infoHash = torrentSummary.infoHash
|
||||
|
||||
// Only show the play/dowload buttons for torrents that contain playable media
|
||||
let playButton
|
||||
if (!torrentSummary.error && TorrentPlayer.isPlayableTorrentSummary(torrentSummary)) {
|
||||
playButton = (
|
||||
<i
|
||||
key='play-button'
|
||||
title='Start streaming'
|
||||
className='icon play'
|
||||
onClick={dispatcher('playFile', infoHash)}
|
||||
>
|
||||
play_circle_outline
|
||||
</i>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='torrent-controls'>
|
||||
{playButton}
|
||||
<i
|
||||
key='delete-button'
|
||||
className='icon delete'
|
||||
title='Remove torrent'
|
||||
onClick={dispatcher('confirmDeleteTorrent', infoHash, false)}
|
||||
>
|
||||
close
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show files, per-file download status and play buttons, and so on
|
||||
renderTorrentDetails (torrentSummary) {
|
||||
let filesElement
|
||||
if (torrentSummary.error || !torrentSummary.files) {
|
||||
let message = ''
|
||||
if (torrentSummary.error === 'path-missing') {
|
||||
// Special case error: this torrent's download dir or file is missing
|
||||
message = 'Missing path: ' + TorrentSummary.getFileOrFolder(torrentSummary)
|
||||
} else if (torrentSummary.error) {
|
||||
// General error for this torrent: just show the message
|
||||
message = torrentSummary.error.message || torrentSummary.error
|
||||
} else if (torrentSummary.status === 'paused') {
|
||||
// No file info, no infohash, and we're not trying to download from the DHT
|
||||
message = 'Failed to load torrent info. Click the download button to try again...'
|
||||
} else {
|
||||
// No file info, no infohash, trying to load from the DHT
|
||||
message = 'Downloading torrent info...'
|
||||
}
|
||||
filesElement = (
|
||||
<div key='files' className='files warning'>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// We do know the files. List them and show download stats for each one
|
||||
const sortByName = this.props.state.saved.prefs.sortByName
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
let fileRows = torrentSummary.files
|
||||
.filter((file) => !file.path.includes('/.____padding_file/'))
|
||||
.map((file, index) => ({ file, index }))
|
||||
|
||||
if (sortByName) {
|
||||
fileRows = fileRows.sort((a, b) => collator.compare(a.file.name, b.file.name))
|
||||
}
|
||||
|
||||
fileRows = fileRows.map((obj) => this.renderFileRow(torrentSummary, obj.file, obj.index))
|
||||
|
||||
filesElement = (
|
||||
<div key='files' className='files'>
|
||||
<table>
|
||||
<tbody>
|
||||
{fileRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key='details' className='torrent-details'>
|
||||
{filesElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show a single torrentSummary file in the details view for a single torrent
|
||||
renderFileRow (torrentSummary, file, index) {
|
||||
// First, find out how much of the file we've downloaded
|
||||
// Are we even torrenting it?
|
||||
const isSelected = torrentSummary.selections && torrentSummary.selections[index]
|
||||
let isDone = false // Are we finished torrenting it?
|
||||
let progress = ''
|
||||
if (torrentSummary.progress && torrentSummary.progress.files &&
|
||||
torrentSummary.progress.files[index]) {
|
||||
const fileProg = torrentSummary.progress.files[index]
|
||||
isDone = fileProg.numPiecesPresent === fileProg.numPieces
|
||||
progress = Math.floor(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
|
||||
}
|
||||
|
||||
// Second, for media files where we saved our position, show how far we got
|
||||
let positionElem
|
||||
if (file.currentTime) {
|
||||
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
|
||||
positionElem = this.renderRadialProgressBar(file.currentTime / file.duration)
|
||||
}
|
||||
|
||||
// Finally, render the file as a table row
|
||||
const isPlayable = TorrentPlayer.isPlayable(file)
|
||||
const infoHash = torrentSummary.infoHash
|
||||
let icon
|
||||
let handleClick
|
||||
if (isPlayable) {
|
||||
icon = 'play_arrow' /* playable? add option to play */
|
||||
handleClick = dispatcher('playFile', infoHash, index)
|
||||
} else {
|
||||
icon = 'description' /* file icon, opens in OS default app */
|
||||
handleClick = isDone
|
||||
? dispatcher('openPath', infoHash, index)
|
||||
: (e) => e.stopPropagation() // noop if file is not ready
|
||||
}
|
||||
// TODO: add a css 'disabled' class to indicate that a file cannot be opened/streamed
|
||||
let rowClass = ''
|
||||
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
|
||||
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
|
||||
return (
|
||||
<tr key={index} onClick={handleClick}>
|
||||
<td className={'col-icon ' + rowClass}>
|
||||
{positionElem}
|
||||
<i className='icon'>{icon}</i>
|
||||
</td>
|
||||
<td className={'col-name ' + rowClass}>
|
||||
{file.name}
|
||||
</td>
|
||||
<td className={'col-progress ' + rowClass}>
|
||||
{isSelected ? progress : ''}
|
||||
</td>
|
||||
<td className={'col-size ' + rowClass}>
|
||||
{prettyBytes(file.length)}
|
||||
</td>
|
||||
<td
|
||||
className='col-select'
|
||||
onClick={dispatcher('toggleTorrentFile', infoHash, index)}
|
||||
>
|
||||
<i className='icon deselect-file'>{isSelected ? 'close' : 'add'}</i>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
renderRadialProgressBar (fraction, cssClass) {
|
||||
const rotation = 360 * fraction
|
||||
const transformFill = { transform: 'rotate(' + (rotation / 2) + 'deg)' }
|
||||
const transformFix = { transform: 'rotate(' + rotation + 'deg)' }
|
||||
|
||||
return (
|
||||
<div key='radial-progress' className={'radial-progress ' + cssClass}>
|
||||
<div className='circle'>
|
||||
<div className='mask full' style={transformFill}>
|
||||
<div className='fill' style={transformFill} />
|
||||
</div>
|
||||
<div className='mask half'>
|
||||
<div className='fill' style={transformFill} />
|
||||
<div className='fill fix' style={transformFix} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inset' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function stopPropagation (e) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
function getErrorMessage (torrentSummary) {
|
||||
const err = torrentSummary.error
|
||||
if (err === 'path-missing') {
|
||||
return (
|
||||
<span key='path-missing'>
|
||||
Path missing.<br />
|
||||
Fix and restart the app, or delete the torrent.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return 'Error'
|
||||
}
|
434
src/renderer/webtorrent.js
Normal file
@ -0,0 +1,434 @@
|
||||
// To keep the UI snappy, we run WebTorrent in its own hidden window, a separate
|
||||
// process from the main window.
|
||||
console.time('init')
|
||||
|
||||
const crypto = require('crypto')
|
||||
const util = require('util')
|
||||
const { ipcRenderer } = require('electron')
|
||||
const fs = require('fs')
|
||||
const mm = require('music-metadata')
|
||||
const networkAddress = require('network-address')
|
||||
const path = require('path')
|
||||
const WebTorrent = require('webtorrent')
|
||||
|
||||
const config = require('../config')
|
||||
const { TorrentKeyNotFoundError } = require('./lib/errors')
|
||||
const torrentPoster = require('./lib/torrent-poster')
|
||||
|
||||
/**
|
||||
* WebTorrent version.
|
||||
*/
|
||||
const VERSION = require('../../package.json').version
|
||||
|
||||
/**
|
||||
* Version number in Azureus-style. Generated from major and minor semver version.
|
||||
* For example:
|
||||
* '0.16.1' -> '0016'
|
||||
* '1.2.5' -> '0102'
|
||||
*/
|
||||
const VERSION_STR = VERSION
|
||||
.replace(/\d*./g, v => `0${v % 100}`.slice(-2))
|
||||
.slice(0, 4)
|
||||
|
||||
/**
|
||||
* Version prefix string (used in peer ID). WebTorrent uses the Azureus-style
|
||||
* encoding: '-', two characters for client id ('WW'), four ascii digits for version
|
||||
* number, '-', followed by random numbers.
|
||||
* For example:
|
||||
* '-WW0102-'...
|
||||
*/
|
||||
const VERSION_PREFIX = '-WD' + VERSION_STR + '-'
|
||||
|
||||
/**
|
||||
* Generate an ephemeral peer ID each time.
|
||||
*/
|
||||
const PEER_ID = Buffer.from(VERSION_PREFIX + crypto.randomBytes(9).toString('base64'))
|
||||
|
||||
// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid
|
||||
// client, as explained here: https://webtorrent.io/faq
|
||||
let client = window.client = new WebTorrent({ peerId: PEER_ID })
|
||||
|
||||
// WebTorrent-to-HTTP streaming sever
|
||||
let server = null
|
||||
|
||||
// Used for diffing, so we only send progress updates when necessary
|
||||
let prevProgress = null
|
||||
|
||||
init()
|
||||
|
||||
function init () {
|
||||
listenToClientEvents()
|
||||
|
||||
ipcRenderer.on('wt-set-global-trackers', (e, globalTrackers) =>
|
||||
setGlobalTrackers(globalTrackers))
|
||||
ipcRenderer.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes, selections) =>
|
||||
startTorrenting(torrentKey, torrentID, path, fileModtimes, selections))
|
||||
ipcRenderer.on('wt-stop-torrenting', (e, infoHash) =>
|
||||
stopTorrenting(infoHash))
|
||||
ipcRenderer.on('wt-create-torrent', (e, torrentKey, options) =>
|
||||
createTorrent(torrentKey, options))
|
||||
ipcRenderer.on('wt-save-torrent-file', (e, torrentKey) =>
|
||||
saveTorrentFile(torrentKey))
|
||||
ipcRenderer.on('wt-generate-torrent-poster', (e, torrentKey) =>
|
||||
generateTorrentPoster(torrentKey))
|
||||
ipcRenderer.on('wt-get-audio-metadata', (e, infoHash, index) =>
|
||||
getAudioMetadata(infoHash, index))
|
||||
ipcRenderer.on('wt-start-server', (e, infoHash) =>
|
||||
startServer(infoHash))
|
||||
ipcRenderer.on('wt-stop-server', () =>
|
||||
stopServer())
|
||||
ipcRenderer.on('wt-select-files', (e, infoHash, selections) =>
|
||||
selectFiles(infoHash, selections))
|
||||
|
||||
ipcRenderer.send('ipcReadyWebTorrent')
|
||||
|
||||
window.addEventListener('error', (e) =>
|
||||
ipcRenderer.send('wt-uncaught-error', { message: e.error.message, stack: e.error.stack }),
|
||||
true)
|
||||
|
||||
setInterval(updateTorrentProgress, 1000)
|
||||
console.timeEnd('init')
|
||||
}
|
||||
|
||||
function listenToClientEvents () {
|
||||
client.on('warning', (err) => ipcRenderer.send('wt-warning', null, err.message))
|
||||
client.on('error', (err) => ipcRenderer.send('wt-error', null, err.message))
|
||||
}
|
||||
|
||||
// Sets the default trackers
|
||||
function setGlobalTrackers (globalTrackers) {
|
||||
globalThis.WEBTORRENT_ANNOUNCE = globalTrackers
|
||||
}
|
||||
|
||||
// Starts a given TorrentID, which can be an infohash, magnet URI, etc.
|
||||
// Returns a WebTorrent object. See https://git.io/vik9M
|
||||
function startTorrenting (torrentKey, torrentID, path, fileModtimes, selections) {
|
||||
console.log('starting torrent %s: %s', torrentKey, torrentID)
|
||||
|
||||
const torrent = client.add(torrentID, {
|
||||
path,
|
||||
fileModtimes
|
||||
})
|
||||
torrent.key = torrentKey
|
||||
|
||||
// Listen for ready event, progress notifications, etc
|
||||
addTorrentEvents(torrent)
|
||||
|
||||
// Only download the files the user wants, not necessarily all files
|
||||
torrent.once('ready', () => selectFiles(torrent, selections))
|
||||
}
|
||||
|
||||
function stopTorrenting (infoHash) {
|
||||
console.log('--- STOP TORRENTING: ', infoHash)
|
||||
const torrent = client.get(infoHash)
|
||||
if (torrent) torrent.destroy()
|
||||
}
|
||||
|
||||
// Create a new torrent, start seeding
|
||||
function createTorrent (torrentKey, options) {
|
||||
console.log('creating torrent', torrentKey, options)
|
||||
const paths = options.files.map((f) => f.path)
|
||||
const torrent = client.seed(paths, options)
|
||||
torrent.key = torrentKey
|
||||
addTorrentEvents(torrent)
|
||||
ipcRenderer.send('wt-new-torrent')
|
||||
}
|
||||
|
||||
function addTorrentEvents (torrent) {
|
||||
torrent.on('warning', (err) =>
|
||||
ipcRenderer.send('wt-warning', torrent.key, err.message))
|
||||
torrent.on('error', (err) =>
|
||||
ipcRenderer.send('wt-error', torrent.key, err.message))
|
||||
torrent.on('infoHash', () =>
|
||||
ipcRenderer.send('wt-parsed', torrent.key, torrent.infoHash, torrent.magnetURI))
|
||||
torrent.on('metadata', torrentMetadata)
|
||||
torrent.on('ready', torrentReady)
|
||||
torrent.on('done', torrentDone)
|
||||
|
||||
function torrentMetadata () {
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipcRenderer.send('wt-metadata', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentReady () {
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipcRenderer.send('wt-ready', torrent.key, info)
|
||||
ipcRenderer.send('wt-ready-' + torrent.infoHash, torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
}
|
||||
|
||||
function torrentDone () {
|
||||
const info = getTorrentInfo(torrent)
|
||||
ipcRenderer.send('wt-done', torrent.key, info)
|
||||
|
||||
updateTorrentProgress()
|
||||
|
||||
torrent.getFileModtimes((err, fileModtimes) => {
|
||||
if (err) return onError(err)
|
||||
ipcRenderer.send('wt-file-modtimes', torrent.key, fileModtimes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a torrent
|
||||
function getTorrentInfo (torrent) {
|
||||
return {
|
||||
infoHash: torrent.infoHash,
|
||||
magnetURI: torrent.magnetURI,
|
||||
name: torrent.name,
|
||||
path: torrent.path,
|
||||
files: torrent.files.map(getTorrentFileInfo),
|
||||
bytesReceived: torrent.received
|
||||
}
|
||||
}
|
||||
|
||||
// Produces a JSON saveable summary of a file in a torrent
|
||||
function getTorrentFileInfo (file) {
|
||||
return {
|
||||
name: file.name,
|
||||
length: file.length,
|
||||
path: file.path
|
||||
}
|
||||
}
|
||||
|
||||
// Every time we resolve a magnet URI, save the torrent file so that we can use
|
||||
// it on next startup. Starting with the full torrent metadata will be faster
|
||||
// than re-fetching it from peers using ut_metadata.
|
||||
function saveTorrentFile (torrentKey) {
|
||||
const torrent = getTorrent(torrentKey)
|
||||
const torrentPath = path.join(config.TORRENT_PATH, torrent.infoHash + '.torrent')
|
||||
|
||||
fs.access(torrentPath, fs.constants.R_OK, err => {
|
||||
const fileName = torrent.infoHash + '.torrent'
|
||||
if (!err) {
|
||||
// We've already saved the file
|
||||
return ipcRenderer.send('wt-file-saved', torrentKey, fileName)
|
||||
}
|
||||
|
||||
// Otherwise, save the .torrent file, under the app config folder
|
||||
fs.mkdir(config.TORRENT_PATH, { recursive: true }, _ => {
|
||||
fs.writeFile(torrentPath, torrent.torrentFile, err => {
|
||||
if (err) return console.log('error saving torrent file %s: %o', torrentPath, err)
|
||||
console.log('saved torrent file %s', torrentPath)
|
||||
return ipcRenderer.send('wt-file-saved', torrentKey, fileName)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Save a JPG that represents a torrent.
|
||||
// Auto chooses either a frame from a video file, an image, etc
|
||||
function generateTorrentPoster (torrentKey) {
|
||||
const torrent = getTorrent(torrentKey)
|
||||
torrentPoster(torrent, (err, buf, extension) => {
|
||||
if (err) return console.log('error generating poster: %o', err)
|
||||
// save it for next time
|
||||
fs.mkdir(config.POSTER_PATH, { recursive: true }, err => {
|
||||
if (err) return console.log('error creating poster dir: %o', err)
|
||||
const posterFileName = torrent.infoHash + extension
|
||||
const posterFilePath = path.join(config.POSTER_PATH, posterFileName)
|
||||
fs.writeFile(posterFilePath, buf, err => {
|
||||
if (err) return console.log('error saving poster: %o', err)
|
||||
// show the poster
|
||||
ipcRenderer.send('wt-poster', torrentKey, posterFileName)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function updateTorrentProgress () {
|
||||
const progress = getTorrentProgress()
|
||||
// TODO: diff torrent-by-torrent, not once for the whole update
|
||||
if (prevProgress && util.isDeepStrictEqual(progress, prevProgress)) {
|
||||
return /* don't send heavy object if it hasn't changed */
|
||||
}
|
||||
ipcRenderer.send('wt-progress', progress)
|
||||
prevProgress = progress
|
||||
}
|
||||
|
||||
function getTorrentProgress () {
|
||||
// First, track overall progress
|
||||
const progress = client.progress
|
||||
const hasActiveTorrents = client.torrents.some(torrent => torrent.progress !== 1)
|
||||
|
||||
// Track progress for every file in each torrent
|
||||
// TODO: ideally this would be tracked by WebTorrent, which could do it
|
||||
// more efficiently than looping over torrent.bitfield
|
||||
const torrentProg = client.torrents.map(torrent => {
|
||||
const fileProg = torrent.files && torrent.files.map(file => {
|
||||
const numPieces = file._endPiece - file._startPiece + 1
|
||||
let numPiecesPresent = 0
|
||||
for (let piece = file._startPiece; piece <= file._endPiece; piece++) {
|
||||
if (torrent.bitfield.get(piece)) numPiecesPresent++
|
||||
}
|
||||
return {
|
||||
startPiece: file._startPiece,
|
||||
endPiece: file._endPiece,
|
||||
numPieces,
|
||||
numPiecesPresent
|
||||
}
|
||||
})
|
||||
return {
|
||||
torrentKey: torrent.key,
|
||||
ready: torrent.ready,
|
||||
progress: torrent.progress,
|
||||
downloaded: torrent.downloaded,
|
||||
downloadSpeed: torrent.downloadSpeed,
|
||||
uploadSpeed: torrent.uploadSpeed,
|
||||
numPeers: torrent.numPeers,
|
||||
length: torrent.length,
|
||||
bitfield: torrent.bitfield,
|
||||
files: fileProg
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
torrents: torrentProg,
|
||||
progress,
|
||||
hasActiveTorrents
|
||||
}
|
||||
}
|
||||
|
||||
function startServer (infoHash) {
|
||||
const torrent = client.get(infoHash)
|
||||
if (torrent.ready) startServerFromReadyTorrent(torrent)
|
||||
else torrent.once('ready', () => startServerFromReadyTorrent(torrent))
|
||||
}
|
||||
|
||||
function startServerFromReadyTorrent (torrent) {
|
||||
if (server) return
|
||||
|
||||
// start the streaming torrent-to-http server
|
||||
server = torrent.createServer()
|
||||
server.listen(0, () => {
|
||||
const port = server.address().port
|
||||
const urlSuffix = ':' + port
|
||||
const info = {
|
||||
torrentKey: torrent.key,
|
||||
localURL: 'http://localhost' + urlSuffix,
|
||||
networkURL: 'http://' + networkAddress() + urlSuffix,
|
||||
networkAddress: networkAddress()
|
||||
}
|
||||
|
||||
ipcRenderer.send('wt-server-running', info)
|
||||
ipcRenderer.send('wt-server-' + torrent.infoHash, info)
|
||||
})
|
||||
}
|
||||
|
||||
function stopServer () {
|
||||
if (!server) return
|
||||
server.destroy()
|
||||
server = null
|
||||
}
|
||||
|
||||
console.log('Initializing...')
|
||||
|
||||
function getAudioMetadata (infoHash, index) {
|
||||
const torrent = client.get(infoHash)
|
||||
const file = torrent.files[index]
|
||||
|
||||
// Set initial matadata to display the filename first.
|
||||
const metadata = { title: file.name }
|
||||
ipcRenderer.send('wt-audio-metadata', infoHash, index, metadata)
|
||||
|
||||
const options = {
|
||||
native: false,
|
||||
skipCovers: true,
|
||||
fileSize: file.length,
|
||||
observer: () => {
|
||||
ipcRenderer.send('wt-audio-metadata', infoHash, index, {
|
||||
common: metadata.common,
|
||||
format: metadata.format
|
||||
})
|
||||
}
|
||||
}
|
||||
const onMetadata = file.done
|
||||
// If completed; use direct file access
|
||||
? mm.parseFile(path.join(torrent.path, file.path), options)
|
||||
// otherwise stream
|
||||
: mm.parseStream(file.createReadStream(), file.name, options)
|
||||
|
||||
onMetadata
|
||||
.then(
|
||||
metadata => {
|
||||
ipcRenderer.send('wt-audio-metadata', infoHash, index, metadata)
|
||||
console.log(`metadata for file='${file.name}' completed.`)
|
||||
},
|
||||
err => {
|
||||
console.log(
|
||||
`error getting audio metadata for ${infoHash}:${index}`,
|
||||
err
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function selectFiles (torrentOrInfoHash, selections) {
|
||||
// Get the torrent object
|
||||
let torrent
|
||||
if (typeof torrentOrInfoHash === 'string') {
|
||||
torrent = client.get(torrentOrInfoHash)
|
||||
} else {
|
||||
torrent = torrentOrInfoHash
|
||||
}
|
||||
if (!torrent) {
|
||||
throw new Error('selectFiles: missing torrent ' + torrentOrInfoHash)
|
||||
}
|
||||
|
||||
// Selections not specified?
|
||||
// Load all files. We still need to replace the default whole-torrent
|
||||
// selection with individual selections for each file, so we can
|
||||
// select/deselect files later on
|
||||
if (!selections) {
|
||||
selections = new Array(torrent.files.length).fill(true)
|
||||
}
|
||||
|
||||
// Selections specified incorrectly?
|
||||
if (selections.length !== torrent.files.length) {
|
||||
throw new Error('got ' + selections.length + ' file selections, ' +
|
||||
'but the torrent contains ' + torrent.files.length + ' files')
|
||||
}
|
||||
|
||||
// Remove default selection (whole torrent)
|
||||
torrent.deselect(0, torrent.pieces.length - 1, false)
|
||||
|
||||
// Add selections (individual files)
|
||||
selections.forEach((selection, i) => {
|
||||
const file = torrent.files[i]
|
||||
if (selection) {
|
||||
file.select()
|
||||
} else {
|
||||
console.log('deselecting file ' + i + ' of torrent ' + torrent.name)
|
||||
file.deselect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Gets a WebTorrent handle by torrentKey
|
||||
// Throws an Error if we're not currently torrenting anything w/ that key
|
||||
function getTorrent (torrentKey) {
|
||||
const ret = client.torrents.find((x) => x.key === torrentKey)
|
||||
if (!ret) throw new TorrentKeyNotFoundError(torrentKey)
|
||||
return ret
|
||||
}
|
||||
|
||||
function onError (err) {
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
// TODO: remove this once the following bugs are fixed:
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=490143
|
||||
// https://github.com/electron/electron/issues/7212
|
||||
window.testOfflineMode = () => {
|
||||
console.log('Test, going OFFLINE')
|
||||
client = window.client = new WebTorrent({
|
||||
peerId: PEER_ID,
|
||||
tracker: false,
|
||||
dht: false,
|
||||
webSeeds: false
|
||||
})
|
||||
listenToClientEvents()
|
||||
}
|
BIN
static/MaterialIcons-Regular.woff2
Executable file
BIN
static/NextTrackThumbnailBarButton.png
Normal file
After Width: | Height: | Size: 225 B |
BIN
static/PauseThumbnailBarButton.png
Normal file
After Width: | Height: | Size: 101 B |
BIN
static/PlayThumbnailBarButton.png
Normal file
After Width: | Height: | Size: 208 B |
BIN
static/PreviousTrackThumbnailBarButton.png
Normal file
After Width: | Height: | Size: 233 B |
BIN
static/WebTorrent.icns
Normal file
BIN
static/WebTorrent.ico
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
static/WebTorrent.png
Normal file
After Width: | Height: | Size: 71 KiB |
BIN
static/WebTorrentFile.icns
Normal file
BIN
static/WebTorrentFile.ico
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
static/WebTorrentFile.png
Normal file
After Width: | Height: | Size: 140 KiB |
39
static/about.html
Normal file
@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ECECEC;
|
||||
font-family: BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
img {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 0.9em;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="../static/WebTorrent.png">
|
||||
<h1>Leenkx Box</h1>
|
||||
<p>
|
||||
Version <script>document.write(require('../package.json').version)</script>
|
||||
(<script>document.write(require('webtorrent/package.json').version)</script>)
|
||||
(<script>document.write(process.arch)</script>)
|
||||
</p>
|
||||
<p><script>document.write(require('../build/config').APP_COPYRIGHT)</script></p>
|
||||
</body>
|
||||
</html>
|
BIN
static/appdmg.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
static/appdmg@2x.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
static/bigBuckBunny.jpg
Normal file
After Width: | Height: | Size: 303 KiB |
BIN
static/bigBuckBunny.torrent
Normal file
BIN
static/cosmosLaundromat.jpg
Normal file
After Width: | Height: | Size: 743 KiB |
BIN
static/cosmosLaundromat.torrent
Normal file
33
static/linux/share/applications/webtorrent-desktop.desktop
Normal file
@ -0,0 +1,33 @@
|
||||
[Desktop Entry]
|
||||
Name=LeenkxBox
|
||||
Version=1.0
|
||||
GenericName=BitTorrent Client
|
||||
X-GNOME-FullName=LeenkxBox
|
||||
Comment=Download and share files over BitTorrent
|
||||
Encoding=UTF-8
|
||||
Type=Application
|
||||
Icon=leenkxbox-desktop
|
||||
Terminal=false
|
||||
Path=/opt/leenkxbox-desktop
|
||||
Exec=/opt/leenkxbox-desktop/LeenkxBox %U
|
||||
TryExec=/opt/leenkxbox-desktop/LeenkxBox
|
||||
StartupNotify=false
|
||||
Categories=Network;FileTransfer;P2P;
|
||||
MimeType=application/x-bittorrent;x-scheme-handler/magnet;x-scheme-handler/stream-magnet;
|
||||
|
||||
Actions=CreateNewTorrent;OpenTorrentFile;OpenTorrentAddress;
|
||||
|
||||
[Desktop Action CreateNewTorrent]
|
||||
Name=Create New Torrent...
|
||||
Exec=/opt/leenkxbox-desktop/LeenkxBox -n
|
||||
Path=/opt/leenkxbox-desktop
|
||||
|
||||
[Desktop Action OpenTorrentFile]
|
||||
Name=Open Torrent File...
|
||||
Exec=/opt/leenkxbox-desktop/LeenkxBox -o
|
||||
Path=/opt/leenkxbox-desktop
|
||||
|
||||
[Desktop Action OpenTorrentAddress]
|
||||
Name=Open Torrent Address...
|
||||
Exec=/opt/leenkxbox-desktop/LeenkxBox -u
|
||||
Path=/opt/leenkxbox-desktop
|
After Width: | Height: | Size: 43 KiB |
After Width: | Height: | Size: 3.6 KiB |
25
static/linux/webtorrent-desktop.ejs
Normal file
@ -0,0 +1,25 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=<%= productName %>
|
||||
<% if (genericName) { %>GenericName=<%= genericName %><% } %>
|
||||
<% if (description) { %>Comment=<%= description %><% } %>
|
||||
Icon=<%= name %>
|
||||
<% if (name) { %>Exec=<%= name %> --no-sandbox %U<% } %><%/*HACK: --no-sandbox fixes an Electron 6 bug. See: #1703*/%>
|
||||
Terminal=false
|
||||
Actions=CreateNewTorrent;OpenTorrentFile;OpenTorrentAddress;
|
||||
<% if (mimeType && mimeType.length) { %>MimeType=<%= mimeType.join(';') %>;<% } %>
|
||||
<% if (categories && categories.length) { %>Categories=<%= categories.join(';') %>;<% } %>
|
||||
StartupNotify=true
|
||||
StartupWMClass=<%= productName %>
|
||||
|
||||
[Desktop Action CreateNewTorrent]
|
||||
Name=Create New Torrent...
|
||||
<% if (name) { %>Exec=<%= name %> --no-sandbox -n <% } %><%/*HACK: --no-sandbox fixes an Electron 6 bug. See: #1703*/%>
|
||||
|
||||
[Desktop Action OpenTorrentFile]
|
||||
Name=Open Torrent File...
|
||||
<% if (name) { %>Exec=<%= name %> --no-sandbox -o <% } %><%/*HACK: --no-sandbox fixes an Electron 6 bug. See: #1703*/%>
|
||||
|
||||
[Desktop Action OpenTorrentAddress]
|
||||
Name=Open Torrent Address...
|
||||
<% if (name) { %>Exec=<%= name %> --no-sandbox -u <% } %><%/*HACK: --no-sandbox fixes an Electron 6 bug. See: #1703*/%>
|
BIN
static/loading.gif
Normal file
After Width: | Height: | Size: 18 KiB |