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 |