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 |