Compare commits

...

333 Commits

Author SHA1 Message Date
d7950fbf70 Merge pull request #1117 from th-ch/release-1-20
Bump version to 1.20.0
2023-05-18 13:11:16 +02:00
TC
c8f12990eb Merge branch 'master' of github.com:th-ch/youtube-music into release-1-20
* 'master' of github.com:th-ch/youtube-music:
  Use 'with blocklists' as default in menu
  add xesam:url mpris from songInfo.url
  Bump cliqz/adblocker-electron
  Implement both blocklists and in-player blocking
  revert adblocker bump
2023-05-12 22:34:08 +02:00
eb14286315 Merge pull request #1134 from th-ch/adblocker-multi-implem
Multiple implementations for the Adblocker plugin
2023-05-12 22:32:14 +02:00
217c3f41ee Merge pull request #1138 from QuditWolf/master
add xesam:url mpris from songInfo.url
2023-05-12 22:28:36 +02:00
TC
a9227b529c Use 'with blocklists' as default in menu 2023-05-12 19:07:51 +02:00
9f77dcc348 add xesam:url mpris from songInfo.url 2023-05-11 14:23:54 +05:30
TC
3b6a7c82ef Bump cliqz/adblocker-electron 2023-05-08 22:23:21 +02:00
TC
69f560cdd1 Implement both blocklists and in-player blocking 2023-05-08 22:23:11 +02:00
c488c30015 Merge pull request #1124 from Araxeus/revert-adblocker-bump
revert adblocker bump
2023-04-26 12:53:48 +02:00
135e1002e6 revert adblocker bump
This is supposed to fix the performance regression introduced in #1100

fix #1105
2023-04-25 13:43:03 +03:00
TC
f51e625c29 Merge branch 'master' of github.com:th-ch/youtube-music into release-1-20
* 'master' of github.com:th-ch/youtube-music:
  fix security issues in deps
  commit assets/generated
  .gitattributes set `eol=lf` on **all** files
  remove `electron.remote` dependency
2023-04-16 22:08:11 +02:00
040946ca9e Merge pull request #1116 from Araxeus/fix-security-issues-in-deps
fix security issues in dependencies
2023-04-16 22:07:22 +02:00
5098ddb98c Merge branch 'local-upstream/master' into fix-security-issues-in-deps 2023-04-15 23:58:17 +03:00
80c152f795 Merge branch 'local-upstream/master' into fix-security-issues-in-deps 2023-04-15 23:57:09 +03:00
9cde19d906 fix security issues in deps 2023-04-15 23:56:27 +03:00
3f33eb8c07 Merge pull request #1118 from Araxeus/commit-assets/generated
commit assets/generated
2023-04-15 22:55:19 +02:00
0bba2980c7 commit assets/generated 2023-04-15 23:53:36 +03:00
7e60049143 Merge pull request #1113 from Araxeus/remove-last-remnant-of-electron.remote
remove `electron.remote` dependency
2023-04-15 22:47:01 +02:00
e74098e9a5 Merge pull request #1115 from Araxeus/fix-eol-diff
.gitattributes set `eol=lf` on *all* files
2023-04-15 22:39:35 +02:00
TC
4fe02baace Bump version to 1.20.0 2023-04-15 22:13:23 +02:00
eecc13852f Merge pull request #1114 from Araxeus/fix-single-instance-lock 2023-04-15 20:39:00 +03:00
2135e01ee1 .gitattributes set eol=lf on **all** files 2023-04-15 20:17:46 +03:00
346d301fe4 fix single instance lock menu checkbox 2023-04-15 20:16:47 +03:00
263a335c96 remove electron.remote dependency
now in renderer check if we are in dev mode using `'npm_package_name' in process.env`

The logic is that we always run the dev mode via npm/yarn and thus that env var will be available
2023-04-15 20:14:12 +03:00
20db77f965 Merge pull request #1102 from Araxeus/caption-selector-use-new-dynamic-configProvider 2023-04-14 23:22:54 +03:00
d0ab23fa88 Merge pull request #1104 from Araxeus/update-dependencies 2023-04-14 23:21:45 +03:00
a669b1ed3a Update yarn.lock 2023-04-14 22:59:54 +03:00
660fce8c99 Merge branch 'local-upstream/master' into update-dependencies 2023-04-14 22:59:36 +03:00
4bfca93713 bump dependencies 2023-04-14 22:49:17 +03:00
bb1295c5f7 Update yarn.lock 2023-04-14 22:46:03 +03:00
5f97255908 Merge pull request #1112 from th-ch/snyk-upgrade-51ddcc2948aa66e1f48f220d96e9a7a3 2023-04-14 22:32:40 +03:00
7a50bbd0c6 Merge pull request #1101 from Araxeus/fix-crossfade-beta-tag 2023-04-14 22:25:25 +03:00
7f7267d806 fix: upgrade html-to-text from 9.0.4 to 9.0.5
Snyk has created this PR to upgrade html-to-text from 9.0.4 to 9.0.5.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2023-04-14 14:21:39 +00:00
12d9b07c8d [caption-selector] use new dynamic configProvider 2023-04-05 13:43:40 +03:00
0b6b707ccd fix crossfade beta tag 2023-04-05 13:41:08 +03:00
TC
5a775b238c Fix name attribute in dynamic config 2023-04-04 22:13:04 +02:00
cad8e4fe83 Merge pull request #1096 from Araxeus/crossfade-beta-psa
[crossfade] add `[beta]` tag to warn of possible bugs
2023-04-04 22:05:40 +02:00
4b9af14c40 Merge pull request #1065 from Araxeus/add-crossfade-menu-options
[crossfade] add menu options
2023-04-04 22:03:49 +02:00
f8db04e3fc Merge pull request #1079 from Araxeus/option-to-load-captions-selector-on-every-song
[captions-selector] add `autoload` option
2023-04-04 21:56:35 +02:00
e8b712b3fa Merge pull request #1091 from Araxeus/fix-new-downloader-metadata
[downloader] Cleanup metadata
2023-04-02 21:31:17 +02:00
556b1a213e Merge pull request #1099 from Araxeus/fix-protocol-handler-on-unix
fix protocol handler on unix
2023-04-02 21:25:07 +02:00
8daf2462ec Merge pull request #1090 from Araxeus/fix-merge-conflict-mistake-in-#1032
fix merge conflict mistake in #1032
2023-04-02 21:24:17 +02:00
99e0eec9fe Merge pull request #1068 from Araxeus/add-pseudo-decorators
Create providers/decorators.js
2023-04-02 21:23:37 +02:00
f36283d63f Merge pull request #1100 from Araxeus/fix-ads-on-start
[adblocker] fix ads showing on program start
2023-04-02 21:21:08 +02:00
1a73e74039 fix ads on start 2023-04-02 19:40:03 +03:00
454061ece9 fix protocol handler on unix
on Windows the arg gets appended with `/` but not on Unix
2023-04-02 18:14:48 +03:00
e6746722c5 add a [beta] tag to crossfade plugin 2023-04-01 16:35:31 +03:00
4c07817b72 fix merge conflict mistake in #1032 2023-03-29 18:02:05 +03:00
ac9b59dc84 [downloader] Cleanup metadata
* Title and artist gets cleaned as before
* We now ignore thumbnail that ends with `.webp` since they cause problems
2023-03-27 23:49:30 +03:00
4e10dab5a8 cache downloader getCoverBuffer() 2023-03-27 22:25:57 +03:00
8124c2b218 lint 2023-03-27 22:25:38 +03:00
fe813df0b5 Merge branch 'local-upstream/master' into add-pseudo-decorators 2023-03-27 21:08:02 +03:00
05278ab643 Merge pull request #1086 from Araxeus/fix-new-downloader-issues
Allow downloading age restricted videos
2023-03-26 22:00:33 +02:00
f722cf86dd Allow downloading age restricted videos
* Bypass age restriction using `androidTvInfo`
* Bump youtubei.js fix #1084
* Add more detailed error messages, including song name or url
2023-03-24 14:59:16 +03:00
b909df9e66 Merge pull request #1073 from Araxeus/add-starting-page-option
add starting page option
2023-03-22 22:04:36 +01:00
55a442e90e lint 2023-03-22 17:39:47 +02:00
af569c3eee Merge branch 'local-upstream/master' into add-starting-page-option 2023-03-21 17:47:40 +02:00
45fa963818 allow default startingPage 2023-03-21 17:46:52 +02:00
764f08ebfb Merge branch 'local-upstream/master' into add-crossfade-menu-options 2023-03-20 00:06:06 +02:00
94f2cbaf06 Merge branch 'local-upstream/master' into option-to-load-captions-selector-on-every-song 2023-03-19 23:52:43 +02:00
2ad097c743 use new dynamic config 2023-03-19 23:48:05 +02:00
648d102101 add subscribe and subscribeAll to config
this primarily allows front.js to have an up to date config without requesting it over ipc every second

for example the crossfade plugin uses its `options.secondsBeforeEnd` every second - so `subscribeAll` would be much more efficient in this case
2023-03-19 23:47:29 +02:00
212009a69b create sendToFront()
TODO: replace all `webcontents.send ` with `sendToFront  = require('../providers/app-controls')`
2023-03-19 23:42:27 +02:00
TC
4364d3be71 Update yarn.lock 2023-03-19 21:33:47 +01:00
62e2e8a471 Merge pull request #1054 from Araxeus/new-downloader
[downloader] plugin overhaul
2023-03-19 21:31:57 +01:00
5d8b04b8d6 Merge branch 'master' into new-downloader 2023-03-19 21:29:37 +01:00
3526197d93 Merge pull request #1070 from th-ch/snyk-upgrade-671b977ed73c84d1af527353a8713dbf
[Snyk] Upgrade @cliqz/adblocker-electron from 1.25.2 to 1.26.0
2023-03-19 20:06:41 +01:00
494b1d9515 Merge branch 'local-upstream/master' into add-crossfade-menu-options 2023-03-19 21:05:23 +02:00
5f71be280b Merge pull request #1072 from Araxeus/fix-uploaded-library-css
[in-app-menu] fix css style of the library of uploaded songs
2023-03-19 20:05:15 +01:00
e8c3716106 use new dynamic config 2023-03-19 21:02:23 +02:00
9840956ef7 Merge pull request #1077 from Araxeus/Add-option-to-remove-like-buttons
add option to hide the like buttons
2023-03-19 20:00:48 +01:00
605401166d Merge branch 'local-upstream/master' into new-downloader 2023-03-19 20:57:55 +02:00
03f4654518 Merge pull request #1081 from danielchalmers/patch-1
Nitpick: Fix name casing in tray icon tooltip
2023-03-19 19:28:59 +01:00
e87099c816 Merge pull request #1082 from DereC4/romanization-patch
[lyrics-genius] Improved reliability of east asian language detection #1080
2023-03-19 19:27:44 +01:00
83eb187d91 Merge pull request #1064 from Araxeus/add-centralized-synced-config-for-plugins
Add dynamic synced plugin config provider
2023-03-19 19:26:47 +01:00
20cdaf2317 Merge pull request #1063 from Araxeus/fix-caption-selector-showing-when-unavailable
[captions-selector] fix button showing when there aren't any captions available
2023-03-19 19:26:06 +01:00
4f4372b65a fix PR review comments 2023-03-19 20:23:09 +02:00
325026e3ea rome lint 2023-03-19 20:15:15 +02:00
a6242d13ae add getActivePlugins and isActive 2023-03-19 03:00:17 +02:00
bc2a1f7f71 Updated Regex to be cleaner 2023-03-18 13:15:25 -05:00
d5c2ad2115 Romanization update 2023-03-17 18:02:48 -05:00
3abef7cb8a Nitpick: Fix name casing in tray icon tooltip 2023-03-17 11:49:22 -05:00
b45c628142 [captions-selector] add autoload option 2023-03-17 00:09:32 +02:00
9d6a78bc57 bump custom-electron-prompt
* enable onwheel in number inputs
* enable wheel event on keybind prompt
2023-03-16 23:50:35 +02:00
7018481b1d Merge branch 'add-centralized-synced-config-for-plugins' into option-to-load-captions-selector-on-every-song 2023-03-16 20:14:12 +02:00
9f9e991aec [crossfade] fix options not saving as numbers 2023-03-16 19:55:30 +02:00
640ba26d55 add option to hide the like buttons
fix #1075
2023-03-16 19:02:09 +02:00
f5758bfe93 add defaults and migrations 2023-03-15 23:27:36 +02:00
a3ea37d412 add starting page option
fix #1071
2023-03-15 23:06:34 +02:00
89c664b4d2 fix uploaded library style
follows up on  [in-app-menu] fix items hidden by navbar in library #1067
2023-03-15 21:52:25 +02:00
51871a3fec catch errors in downloadSong() 2023-03-15 20:29:12 +02:00
04ca4e8537 fix: upgrade @cliqz/adblocker-electron from 1.25.2 to 1.26.0
Snyk has created this PR to upgrade @cliqz/adblocker-electron from 1.25.2 to 1.26.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2023-03-15 15:15:48 +00:00
023258f1d7 fix crossfade option prompt height 2023-03-15 08:24:50 +02:00
848bb36c64 rename defaultOptions to config 2023-03-14 23:40:46 +02:00
a6e9c140fe Merge branch 'local-upstream/master' into add-crossfade-menu-options 2023-03-14 23:39:19 +02:00
e972fd15c2 Merge pull request #1067 from Araxeus/fix-in-app-menu-navbar-hiding-library-items
[in-app-menu] fix items hidden by navbar in library
2023-03-14 22:34:57 +01:00
ed5fb06504 Merge pull request #1061 from Araxeus/fix-app-logo-is-draggable
Fix Youtube Music logo is draggable
2023-03-14 22:30:46 +01:00
51c2b76c8b Merge pull request #1069 from Araxeus/fix-check-action-failing-on-forks
fix build action failing on forks, and run it on pull requests
2023-03-14 22:25:47 +01:00
f193c0b547 Merge pull request #1059 from Araxeus/rename-single-instance-lock-menu-name 2023-03-14 17:20:22 +02:00
7ac3cf69b6 try to fix songInfo time&album (#1032) 2023-03-14 17:18:40 +02:00
69cd5ed613 [lyrics] Romanization toggle for Genius plugin (#1039) 2023-03-14 17:16:41 +02:00
1649bd9c2d run build.yml on pull request and pushes to master 2023-03-12 22:14:44 +02:00
a97888a207 fix check action failing on forks 2023-03-12 22:03:34 +02:00
f1073e37b5 use pseudo decorators 2023-03-12 21:41:15 +02:00
7abc67b456 Create providers/decorators.js 2023-03-12 21:35:03 +02:00
b652a011a5 lint 2023-03-12 20:00:10 +02:00
83abbdb25a add caching to getCoverBuffer
when downloading an album, will no longer re-download an encode identical cover images
2023-03-12 19:53:25 +02:00
837e888462 [in-app-menu] fix items hidden by navbar in library 2023-03-12 19:40:51 +02:00
66ccd71b7c [crossfade] add menu options 2023-03-12 02:14:23 +02:00
108c778f6d fix caption selector showing when unavailable 2023-03-12 02:07:04 +02:00
af2b6782e8 bump custom-electron-prompt 2023-03-12 02:07:03 +02:00
7d93e9f031 fix config.setAll() 2023-03-12 02:04:29 +02:00
8c311bf630 bump custom-electron-prompt 2023-03-12 01:59:21 +02:00
bdfdf83c24 [notifications] use dynamic config 2023-03-12 00:28:15 +02:00
96e6b5d018 Add dynamic synced plugin config provider 2023-03-12 00:28:15 +02:00
c5ef9a9ebb fix app logo is draggable 2023-03-10 15:14:07 +02:00
4ace5e3647 Rename Release single instance lock to Single instance lock 2023-03-09 19:40:50 +02:00
TC
476e13de9f Update yarn.lock for latest package.json 2023-03-08 21:29:04 +01:00
659cb35525 Merge pull request #1056 from th-ch/snyk-upgrade-10b2d3763ea9ee07c6cd4884a5661cfa
[Snyk] Upgrade html-to-text from 9.0.3 to 9.0.4
2023-03-08 21:26:49 +01:00
a507a8cd71 Merge pull request #988 from Araxeus/in-app-menu-icon
[in-app-menu] add toggle menu icon
2023-03-08 21:21:59 +01:00
8cca8c89b3 Merge pull request #1048 from Araxeus/fix-playback-speed-selector
Fix playback speed slider not showing and PiP button showing when it shouldn't
2023-03-08 21:15:36 +01:00
476b45096c Merge pull request #1052 from Araxeus/fix-lyrics-bugs
[lyrics-genius] Fix lyrics not showing up or showing up when they shouldn't
2023-03-08 21:12:25 +01:00
43be177b66 Merge pull request #1055 from Araxeus/fix-in-app-menu-drag-area
[in-app-menu] disable nav-bar drag when menu is open
2023-03-08 21:10:07 +01:00
2edeab567f Merge pull request #946 from Araxeus/use-ToastXML
[Notifications] [Windows] Native interactive notifications
2023-03-08 20:59:34 +01:00
26fb48fd37 Merge pull request #1049 from Araxeus/automate-winget-releases
automate winget releases
2023-03-08 20:52:10 +01:00
c5781962f4 lint 2023-03-05 18:56:04 +02:00
61dd477c27 fix: upgrade html-to-text from 9.0.3 to 9.0.4
Snyk has created this PR to upgrade html-to-text from 9.0.3 to 9.0.4.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2023-03-04 14:21:37 +00:00
6ca64d68ca show ffmpeg progress 2023-03-04 16:17:39 +02:00
ad484ab745 use centralized config 2023-03-04 15:48:15 +02:00
7b3280c12b add skip existing files option 2023-03-04 15:17:54 +02:00
cd41f093be fix download button not showing first time menu is opened 2023-03-04 15:15:01 +02:00
a2eb3a3319 helpful error when trying to download a private or "mixed for you" playlist 2023-03-04 14:16:22 +02:00
e4b1d38f85 dont add album to metadata if it's ===N/A 2023-03-04 13:51:18 +02:00
560e323893 lint 2023-03-04 13:40:03 +02:00
a31e59fbc7 fix download button showing when it shouldn't 2023-03-04 13:39:49 +02:00
2cbc73d2ed remove unused imports 2023-03-04 12:31:03 +02:00
099e5d8491 add download feedback and progress 2023-03-04 12:12:23 +02:00
54d3f925e6 add trackId to album downloads 2023-03-04 01:53:19 +02:00
ec6107138d remove node-id3 2023-03-04 01:09:07 +02:00
83d7befc45 disable nav-bar drag when menu is open 2023-03-03 23:37:24 +02:00
b6f9404ff5 download using youtubei,js instead of ytdl-core 2023-03-03 23:19:23 +02:00
16a0b6a893 add slight delay to lyrics genius
this allows youtube to finish doing it's stuff

fix #1041 and other lyrics issues
2023-03-02 19:55:17 +02:00
9ff40611ce fix unescaped url params
fix #1050
2023-03-02 18:18:13 +02:00
f2d20d05c4 [winget] add installer regex 2023-02-28 23:34:40 +02:00
f650ee1e70 automate winget releases 2023-02-28 22:42:02 +02:00
ab7ba1c280 fix PiP button showing in non player-bar menu's 2023-02-28 21:10:28 +02:00
c4ced889b5 resolve merge error 2023-02-28 20:35:10 +02:00
79e71dae26 fix playback speed selector
replace querying for the text `Stats` (which is language specific)
with querying that the event origin is the button on the player-bar

fix #1045
2023-02-28 19:56:16 +02:00
87cbdc3dc3 fix potential empty notification showing 2023-02-17 18:05:16 +02:00
a3c5be0cc2 Merge branch 'local-upstream/master' into use-ToastXML 2023-02-17 18:02:30 +02:00
0e3982d199 Merge branch 'local-upstream/master' into in-app-menu-icon 2023-02-17 17:44:48 +02:00
8bfbbca044 Merge pull request #1029 from th-ch/windows-arm-build
build win target on ARM
2023-02-15 21:09:04 +01:00
TC
35fa794395 build win target on ARM 2023-02-15 20:52:36 +01:00
TC
bfb392a326 Use a promise to wait for transitions in crossfade plugin 2023-02-12 20:00:48 +01:00
TC
8adfcdc002 Replace all | in title (codeQL fix) 2023-02-12 19:21:04 +01:00
TC
4ec0b7ff30 Bump yarn.lock 2023-02-12 19:13:20 +01:00
0e683444d3 Merge pull request #961 from xhayper/patch-1
feat: auto reconnect rpc and CSP fix
2023-02-12 19:12:44 +01:00
7282a227fd Merge branch 'master' into patch-1 2023-02-12 19:08:54 +01:00
c0d0c267a7 Merge branch 'local-upstream/master' into in-app-menu-icon 2023-02-12 19:58:10 +02:00
6577bcdad8 Merge pull request #989 from Araxeus/in-app-menu-draggable-navbar
[in-app-menu] make navbar draggable
2023-02-12 18:50:56 +01:00
34f56df2ec Merge pull request #1013 from th-ch/native-pip
Add option `useNativePiP` in PiP plugin to use native PiP
2023-02-12 18:50:26 +01:00
TC
7f66ff2c0c Merge branch 'master' of github.com:th-ch/youtube-music into native-pip
* 'master' of github.com:th-ch/youtube-music:
  fix PiP hotkey active in searchbox
  remove titlebar from in-app-menu+PiP
  Bump "@cliqz/adblocker-electron" version
  Only run the release stage if it is the main repo
  Only build without release if it is a fork
  Use del-cli instead of del (for windows)
  Replace electron-icon-maker by a more up-to-date fork
  Adapt CI to yarn v3
  Migrate to yarn v3
  Track transitioning status
  Fixed recursive volume changes that caused cpu spike, Switched Repeat Modes to NONE|ONE|ALL
  Remove references to rimraf
  Add first version for crossfade plugin
  removed unnecessary if and used better Repeat change detection
  connected mpris shuffle, fixed volume, mpris volumes allowed 0.0-1.0
  fixed 'repeatChanged' modes being different depending on selected youtube music language
  fix precise-volume+searchbox interaction
  fix navbar position
2023-02-12 18:41:52 +01:00
735fd39c3c Merge pull request #1025 from Araxeus/fix-PiP-hotkey-active-in-search
[PiP] fix hotkey activating when typing in the search box
2023-02-12 18:34:29 +01:00
7df7b32eea Merge pull request #1024 from Araxeus/in-app-menu-pip-no-titlebar
[PiP] Remove titlebar when in-app-menu is enabled
2023-02-12 18:33:04 +01:00
d9f1c589e9 fix PiP hotkey active in searchbox 2023-02-10 20:14:07 +02:00
5dd8d1a274 remove titlebar from in-app-menu+PiP
Results in an experience similar to the native PiP, except plugins can work (for example precise-volume)
2023-02-10 18:52:51 +02:00
2f117117d8 lint 2023-02-10 01:37:42 +02:00
d8a6453a8d Merge branch 'local-upstream/master' into in-app-menu-icon 2023-02-10 00:14:39 +02:00
27d8bbdf85 Merge branch 'local-upstream/master' into use-ToastXML 2023-02-09 23:55:13 +02:00
7bdbab5a2d Merge pull request #1005 from Skydeke/master
[Shortcuts] MPRIS fixes, Repeat Language bug fix
2023-02-09 22:47:03 +01:00
96f23ea8d5 Merge branch 'local-upstream/master' into use-ToastXML 2023-02-09 23:43:26 +02:00
0a8ac31c1e Merge branch 'master' into master 2023-02-09 22:42:39 +01:00
e0d7117970 Merge pull request #1023 from th-ch/build-without-release-in-forks
Build without release in forks
2023-02-09 22:39:34 +01:00
TC
e70f843ac3 Bump "@cliqz/adblocker-electron" version 2023-02-09 22:38:07 +01:00
7932408b47 Merge pull request #997 from Araxeus/fix-navbar-position
[in-app-menu] fix navbar position
2023-02-09 22:33:45 +01:00
97c6cad503 Avoid video pause
Co-authored-by: Araxeus <oaeben@gmail.com>
2023-02-09 22:33:07 +01:00
455a89ad28 Merge pull request #1022 from th-ch/yarn-new-version
Migrate to yarn v3
2023-02-09 22:11:49 +01:00
TC
9ec07b5fb7 Only run the release stage if it is the main repo 2023-02-09 22:11:26 +01:00
TC
b9aa6ffdd4 Only build without release if it is a fork 2023-02-09 22:08:40 +01:00
ff1847d1e2 Merge branch 'local-upstream/master' into use-ToastXML 2023-02-09 18:51:24 +02:00
fa4a55f97e Merge branch 'master' into patch-1 2023-02-09 10:06:35 +07:00
TC
781a726f4b Add menu option for native PiP 2023-02-08 23:46:43 +01:00
TC
0b49d57969 Always listen for toggle 2023-02-08 23:46:24 +01:00
70c55ca587 Merge pull request #1002 from Araxeus/fix-searchbox+precise-volume-interaction
[precise-volume] fix arrows shortcuts active in search box
2023-02-08 23:30:40 +01:00
TC
3277a8e6c9 Use del-cli instead of del (for windows) 2023-02-08 22:20:44 +01:00
TC
9c54fccf93 Replace electron-icon-maker by a more up-to-date fork 2023-02-08 22:20:44 +01:00
TC
f3a6d4dd18 Adapt CI to yarn v3 2023-02-08 22:20:42 +01:00
TC
721b048151 Migrate to yarn v3 2023-02-08 22:13:32 +01:00
35859a6c3a Merge pull request #1012 from th-ch/crossfade-plugin
[new plugin] Add first version for crossfade plugin
2023-02-07 21:35:28 +01:00
TC
7f099eef4e Track transitioning status 2023-02-06 23:21:12 +01:00
9da0e4305f Fixed recursive volume changes that caused cpu spike, Switched Repeat Modes to NONE|ONE|ALL 2023-02-03 12:03:17 +01:00
a81476100b Merge branch 'th-ch:master' into master 2023-02-03 12:02:53 +01:00
TC
7cbc99fc19 Remove references to rimraf 2023-02-01 23:23:34 +01:00
TC
8a9a3fc69d Add option useNativePiP in PiP plugin to use native PiP 2023-02-01 22:25:33 +01:00
TC
f422b25cb6 Add first version for crossfade plugin 2023-02-01 22:19:39 +01:00
TC
d44fb5c840 Fix audio source not connected to the context 2023-01-31 13:49:50 +01:00
TC
b4713196fe Fix audio-compressor plugin by only applying it once 2023-01-31 13:49:26 +01:00
8bf2c8397e removed unnecessary if and used better Repeat change detection 2023-01-28 20:36:31 +01:00
317e3af412 connected mpris shuffle, fixed volume, mpris volumes allowed 0.0-1.0 2023-01-28 15:43:16 +01:00
a4a3564136 fixed 'repeatChanged' modes being different depending on selected youtube music language 2023-01-28 15:42:32 +01:00
bc49e09810 fix precise-volume+searchbox interaction 2023-01-24 00:45:34 +02:00
79890e019a fix navbar position 2023-01-22 20:22:14 +02:00
70361afbaf use css instead of js 2023-01-22 19:25:29 +02:00
333b695b16 lint 2023-01-22 19:18:10 +02:00
c6bb0cfe88 remove draggable attribute if search box is open 2023-01-19 19:16:01 +02:00
b665343fd9 make navbar draggable [in-app-menu] 2023-01-19 18:52:40 +02:00
236034a1f9 bump custom-electron-titlebar 2023-01-19 17:33:35 +02:00
c61a719f59 Merge branch 'master' into patch-1 2023-01-19 13:14:32 +07:00
96b1b69629 hide menu in PiP+in-app-menu if hideMenu is enabled 2023-01-19 02:33:43 +02:00
1eb0269434 Differentiate between refresh/toggle menu
+ visible now checks the actual state to fix PiP bugs
2023-01-19 02:32:57 +02:00
5909af42d2 Update readme.md 2023-01-19 02:05:16 +02:00
4eaeaafa7c add menu icon to in-app-menu 2023-01-19 01:43:28 +02:00
fe42f8d953 fix upstream merge bug 2023-01-18 19:19:40 +02:00
0f09f8a8ed Merge pull request #984 from th-ch/th-ch/fix-bypass-age-import
Fix bypass-age-restriction lib import
2023-01-17 21:58:42 +01:00
cb2c9fe1cd re-create yarn.lock 2023-01-16 22:28:34 +02:00
b5cddd2d49 Merge branch 'local-upstream/master' into use-ToastXML 2023-01-16 21:36:47 +02:00
4957bccaad prepare for merge 2023-01-16 21:32:18 +02:00
TC
b518866d24 Fix bypass-age-restriction lib import 2023-01-15 21:12:12 +01:00
a51ed89281 Merge pull request #977 from th-ch/th-ch/option-to-copy-url
Add menu entry to copy current URL
2023-01-15 20:45:27 +01:00
3482ec4ec7 Merge pull request #979 from th-ch/th-ch/remove-deprecated-code
Remove deprecated code
2023-01-15 20:41:40 +01:00
TC
3c3530367a nit: trim trailing whitespace 2023-01-15 14:43:22 +01:00
TC
fae1f67a64 Remove deprecated code with electron v22 2023-01-15 14:43:06 +01:00
TC
eb9b0b4cd1 Use electron clipboard 2023-01-15 14:39:36 +01:00
91e111d483 feat: apply suggestion 2023-01-15 10:59:56 +07:00
dbfddebbc2 feat: auto reconnect rpc and CSP fix 2023-01-15 09:57:35 +07:00
TC
b541dd0312 Add menu entry to copy current URL 2023-01-14 23:10:45 +01:00
3a822f611a Merge pull request #976 from th-ch/th-ch/update-dev-deps
Update dev dependencies
2023-01-14 22:58:58 +01:00
TC
0c53f7ffeb Update dev dependencies 2023-01-14 17:15:17 +01:00
acdff69919 Merge pull request #974 from th-ch/th-ch/update-electron-and-other-dependencies
Update electron and various dependencies
2023-01-14 17:02:06 +01:00
TC
a80219ae40 Update electron and various dependencies 2023-01-14 16:40:07 +01:00
fecafe5a19 Merge pull request #973 from th-ch/th-ch/dependency-review
Add CI job for dependency review
2023-01-14 16:26:33 +01:00
TC
a8769faea8 Add captions plugin readme 2023-01-14 16:21:23 +01:00
3ed4a30915 Merge pull request #972 from th-ch/th-ch/captions-plugin
Improve captions plugin
2023-01-14 16:16:42 +01:00
TC
832195f29c Add CI job for dependency review 2023-01-14 16:15:19 +01:00
9869063a5d Merge pull request #817 from anytarseir67/master
fix malformed json in tuna-obs
2023-01-14 16:11:48 +01:00
TC
b63e7ed402 Use custom-electron-prompt in caption selector 2023-01-14 16:08:33 +01:00
TC
1b7bb4703a Snakecase template name 2023-01-14 16:07:24 +01:00
2cc347ff94 Merge branch 'master' into use-ToastXML 2023-01-14 16:08:34 +02:00
4674a25746 Merge pull request #866 from LetrixZ/master
Add Captions selector
2023-01-14 15:06:09 +01:00
237423da1d Merge branch 'master' into master 2023-01-14 15:02:01 +01:00
6edc94ae98 Merge pull request #941 from Araxeus/fix-snoretoast
fix SnoreToast implementation
2023-01-14 15:00:32 +01:00
98e677a76f Merge pull request #942 from th-ch/dependabot/npm_and_yarn/json5-1.0.2
Bump json5 from 1.0.1 to 1.0.2
2023-01-14 14:57:46 +01:00
d289b30782 Merge pull request #969 from th-ch/snyk-upgrade-a42642fbff21eea4c8d029ab972233d2
[Snyk] Upgrade custom-electron-titlebar from 4.1.3 to 4.1.5
2023-01-14 14:56:35 +01:00
9b14a274ce Merge pull request #956 from MiepHD/master
Fixed video-toggle aligning running before #main-panel exists
2023-01-14 14:55:51 +01:00
7701c03e2b Merge pull request #953 from th-ch/visualizer-plugin
[New plugin] Music visualizers
2023-01-14 14:53:29 +01:00
0cf72074f3 Merge pull request #964 from Araxeus/fix-PiP-button
fix PiP buttons not showing up
2023-01-14 14:47:31 +01:00
TC
6e96b355bd Add migration for visualizer plugin 2023-01-14 14:44:02 +01:00
210a16a32b Update back.js 2023-01-14 14:36:40 +01:00
3389679287 fix: upgrade custom-electron-titlebar from 4.1.3 to 4.1.5
Snyk has created this PR to upgrade custom-electron-titlebar from 4.1.3 to 4.1.5.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2023-01-14 02:08:25 +00:00
06eacea9a5 fix misnamed options in menu 2023-01-13 00:28:30 +02:00
759b3844db lint 2023-01-12 20:51:33 +02:00
2b4e996743 add migrations 2023-01-12 20:35:42 +02:00
0e99f96f01 fix PiP unminimize button hidden 2023-01-12 19:58:44 +02:00
b3f561cf2f disable maximize button on PiP
(doesn't work if `in-app-menu` is enabled)
2023-01-12 19:58:36 +02:00
f9820df6c6 fix PiP button
fix #959
2023-01-12 19:18:58 +02:00
bc5023c360 Add 2 more config options
refreshOnPlayPause: false
trayControls: true,
2023-01-09 23:46:44 +02:00
1c5d61854e fixes from pr review 2023-01-09 19:20:11 +02:00
a8f3451e04 Merge branch 'th-ch:master' into master 2023-01-09 18:18:44 +01:00
8728784c02 Fixed running before #main-panel exists 2023-01-09 18:16:33 +01:00
TC
b77c62128e Implement visualizer plugin 2023-01-09 09:23:40 +01:00
70522173b7 Interactive Notifications v2 2023-01-09 00:07:07 +02:00
35bd62cc0d Merge pull request #951 from th-ch/audio-context-source
Use same audio context/source everywhere
2023-01-08 19:38:09 +01:00
TC
52b67af59c Use same audio context/source everywhere 2023-01-08 19:17:40 +01:00
027d4ce3f0 Added variations for testing
`xml_logo_ascii`
`xml_logo_icons`
`xml_logo_icons_notext`
`xml_hero`
`xml_banner_bottom`
`xml_banner_top_custom`
`xml_banner_centered_bottom`
`xml_banner_centered_top`
2023-01-08 13:12:01 +02:00
05d0ac963a re-add cover_url 2023-01-07 20:47:13 -05:00
97c5dc25be save temp icons for file:/// protocol 2023-01-08 01:29:44 +02:00
14b0315ed9 add back to front logger 2023-01-08 01:29:20 +02:00
2c49f6c740 use Electron with ToastXML instead of SnoreToast
* Add support for protocol commands
* Remove node-notifier dependency
2023-01-07 22:06:46 +02:00
bc23131e48 Bump json5 from 1.0.1 to 1.0.2
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-07 04:35:10 +00:00
e6146940b1 revert #600 2023-01-07 05:34:24 +02:00
3412b3504f use snoretoast CLSID 2023-01-07 05:23:42 +02:00
fcb92fda84 Update changelog for v1.19.0 2022-12-31 12:48:44 +00:00
TC
51fdbe2086 Bump version to 1.19.0 2022-12-31 13:32:27 +01:00
74535b696c Merge pull request #936 from th-ch/github-action-publish-release
Automatic release by CI when version is updated
2022-12-30 19:18:43 +01:00
TC
31ab27c39f Bump version and change release type when publishing a new version 2022-12-30 19:09:32 +01:00
a13606b361 Merge pull request #894 from MiepHD/master
Center toggle of video-toggle
2022-12-29 22:37:55 +01:00
TC
d0ed64928d Update readme to get have precise build instructions 2022-12-28 17:54:27 +01:00
2b8b825f4c Merge branch 'th-ch:master' into master 2022-12-28 14:50:15 +01:00
74c9fe13e2 Added option to change alignment of video-toggle 2022-12-28 10:23:11 +01:00
a2a2f18058 Merge pull request #890 from th-ch/load-plugins-on-window-creation
Load plugins as soon as the window is created
2022-12-27 18:41:50 +01:00
e587f02bd9 Merge pull request #913 from th-ch/dependabot/npm_and_yarn/qs-6.5.3
Bump qs from 6.5.2 to 6.5.3
2022-12-27 18:40:06 +01:00
c38c416813 Bump qs from 6.5.2 to 6.5.3
Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-27 17:27:53 +00:00
138b6df5a4 Merge pull request #900 from th-ch/snyk-upgrade-cf02914a196acf6d7c9613f310f238f5
[Snyk] Upgrade custom-electron-titlebar from 4.1.1 to 4.1.2
2022-12-27 18:27:24 +01:00
cf2add8d91 Merge pull request #931 from th-ch/th-ch/skip-silences-beginning
Add option in skip-silences plugin to only skip at the beginning
2022-12-27 17:29:50 +01:00
453fe3f87a Merge pull request #932 from th-ch/rimraf-del
Replace rimraf by del-cli
2022-12-27 17:28:52 +01:00
TC
ccedb17545 Replace rimraf by del-cli 2022-12-26 23:33:30 +01:00
TC
43c501b6d8 Add option in skip-silences plugin to only skip at the beginning 2022-12-26 18:46:52 +01:00
TC
a1bed628f4 Update build badge 2022-12-25 23:16:18 +01:00
7052a74a77 Merge pull request #873 from Nixxen/master
docs: Added winget install instructions
2022-12-25 23:12:44 +01:00
254758a4f2 fix: upgrade custom-electron-titlebar from 4.1.1 to 4.1.2
Snyk has created this PR to upgrade custom-electron-titlebar from 4.1.1 to 4.1.2.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-11-28 08:48:16 +00:00
5d85108c8a doc: Updated readme to include note about MSDSS 2022-11-23 00:40:04 +01:00
46bfec299c Centered toggle 2022-11-22 17:56:23 +01:00
de7bc828b1 Appended directly to the main-panel 2022-11-22 17:55:50 +01:00
TC
335d515e22 Do not skip silences if volume is 0 or video is muted 2022-11-20 21:54:23 +01:00
TC
3fb219fcd1 Bump age restriction plugin 2022-11-20 21:15:05 +01:00
64114e8e9d Merge pull request #855 from th-ch/snyk-upgrade-0a2e4b6ab9f1a14b5e27d5de213bed41
[Snyk] Upgrade async-mutex from 0.3.2 to 0.4.0
2022-11-20 21:03:00 +01:00
ca6225d47b Merge pull request #856 from th-ch/snyk-upgrade-745c57baec21283a223fa43b3f450118
[Snyk] Upgrade @cliqz/adblocker-electron from 1.25.0 to 1.25.1
2022-11-20 21:01:23 +01:00
bf580645ae Merge pull request #865 from th-ch/snyk-upgrade-307bfc7d5d38af8f17908cd31fafc230
[Snyk] Upgrade custom-electron-titlebar from 4.1.0 to 4.1.1
2022-11-20 20:59:57 +01:00
4361cf2b2b Merge pull request #876 from th-ch/snyk-upgrade-fc79a6530d88cc21f2f068311b2363b9
[Snyk] Upgrade @ffmpeg/ffmpeg from 0.11.5 to 0.11.6
2022-11-20 20:59:22 +01:00
TC
c2fbc89b91 Load plugins as soon as the window is created 2022-11-20 20:30:35 +01:00
3605e32b25 Merge pull request #888 from Zo-Bro-23/master
Discord Plugin RPC Fix
2022-11-20 20:25:57 +01:00
49eae89886 Update back.js 2022-11-20 17:34:20 +05:30
ee01ae1c00 Update back.js 2022-11-20 17:33:06 +05:30
d199a5fce9 Update back.js 2022-11-20 16:21:32 +05:30
350e8fb706 fix: upgrade @ffmpeg/ffmpeg from 0.11.5 to 0.11.6
Snyk has created this PR to upgrade @ffmpeg/ffmpeg from 0.11.5 to 0.11.6.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-11-11 23:13:48 +00:00
3fdc6e2f09 docs: Added winget install instructions 2022-11-06 18:34:54 +01:00
938210e8f9 Plugin Captions Selector - Add disable captions by default 2022-10-22 01:49:16 -03:00
c8a852bf2e Plugin Captions Selector - Check if there is caption tracks available, add "disable captions" 2022-10-22 01:13:04 -03:00
f58c10b02d Plugin Captions Selector - Add new line 2022-10-22 01:01:25 -03:00
c281b8ba98 Plugin: Captions Selector 2022-10-22 01:00:15 -03:00
1fef3c4aab fix: upgrade custom-electron-titlebar from 4.1.0 to 4.1.1
Snyk has created this PR to upgrade custom-electron-titlebar from 4.1.0 to 4.1.1.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-10-19 17:03:11 +00:00
762ef4eede fix: upgrade @cliqz/adblocker-electron from 1.25.0 to 1.25.1
Snyk has created this PR to upgrade @cliqz/adblocker-electron from 1.25.0 to 1.25.1.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-10-10 00:44:14 +00:00
fe9b26ebdd fix: upgrade async-mutex from 0.3.2 to 0.4.0
Snyk has created this PR to upgrade async-mutex from 0.3.2 to 0.4.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-10-10 00:44:11 +00:00
77173c1347 Merge pull request #854 from th-ch/bump-ffmpeg
Bump FFMpeg
2022-10-09 13:15:59 +02:00
TC
be3a2880eb Bump FFMpeg 2022-10-09 12:32:26 +02:00
d761d92861 Merge pull request #823 from th-ch/snyk-upgrade-da17b83c582728aa38ca18a57844b1ef
[Snyk] Upgrade @cliqz/adblocker-electron from 1.23.8 to 1.23.9
2022-10-09 12:16:55 +02:00
0e7fd4d36d Merge pull request #801 from th-ch/snyk-upgrade-1bb0065cafdd8e20657abaf4608e914b
[Snyk] Upgrade electron-store from 8.0.2 to 8.1.0
2022-10-09 12:15:11 +02:00
073ea27bba Merge pull request #802 from amsyarasyiq/master
proposal: Adding an option to hide duration before the song ends
2022-10-09 12:12:10 +02:00
9441a6a694 Merge pull request #790 from th-ch/snyk-fix-7c02df943121127bc4ba140fcd2b10b7
[Snyk] Security upgrade node-fetch from 2.6.7 to 3.2.10
2022-10-09 12:06:19 +02:00
TC
c9f610f7fc Lock node-fetch to v2 for commonJS 2022-10-09 12:04:47 +02:00
22b75bbfeb Merge pull request #807 from kerichdev/patch-1
Update README.md with a new theme repo
2022-10-09 11:59:45 +02:00
0063be02fb Merge pull request #822 from andrew-mathieu/andrew-mathieu-patch-1
Fix likes on touchbar (they were inverted)
2022-10-09 11:55:45 +02:00
cc1c13cece Merge pull request #839 from pcgeek86/patch-1
Add Scoop install directions for Windows 🪟
2022-10-09 11:55:13 +02:00
TC
7f96c89f41 Remove jest config (not used anymore) 2022-10-09 11:53:57 +02:00
cdb8bdcfb4 Add Scoop install directions for Windows 🪟 2022-09-20 14:11:39 -06:00
8c817e0862 fix: upgrade @cliqz/adblocker-electron from 1.23.8 to 1.23.9
Snyk has created this PR to upgrade @cliqz/adblocker-electron from 1.23.8 to 1.23.9.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-09-09 23:20:14 +00:00
b4ec6a791d Fix likes on touchbar (they were inverted)
When we tap on "👍", it doesn't leave a like but does the opposite
2022-09-09 03:10:22 +02:00
82ced02a5e fix malformed json in tuna-obs 2022-09-05 04:07:46 -04:00
8bbf18cd6b Update README.md with a new theme repo
The repo referenced is currently unmaintained, so I made a fork with some fixes and improvements, with more to come. Maybe a good idea to reference it as well / replace it?
2022-08-31 10:55:43 +03:00
927596d0c1 fix: set default option for hideDurationLeft 2022-08-23 22:56:14 +08:00
0c0cb0501c Add an option to hide duration before the song ends 2022-08-23 21:00:52 +08:00
a2847c5007 fix: upgrade electron-store from 8.0.2 to 8.1.0
Snyk has created this PR to upgrade electron-store from 8.0.2 to 8.1.0.

See this package in npm:


See this project in Snyk:
https://app.snyk.io/org/th-ch/project/81809c53-bb7b-46b9-a0d7-806d45d74ac6?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-08-22 23:39:40 +00:00
f6b3347d0a fix: package.json & yarn.lock to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-NODEFETCH-2964180
2022-07-31 23:37:38 +00:00
109 changed files with 12266 additions and 8050 deletions

4
.gitattributes vendored
View File

@ -1,2 +1,2 @@
* text=auto
*.js text eol=lf
* text=auto eol=lf
*.js text

View File

@ -1,7 +1,12 @@
name: Build YouTube Music
on:
- push
push:
branches: [ master ]
pull_request:
env:
NODE_VERSION: "16.x"
jobs:
build:
@ -18,76 +23,42 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v3
with:
node-version: "16.x"
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Expose yarn config as "$GITHUB_OUTPUT"
id: yarn-config
shell: bash
run: |
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
id: yarn-cache
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
# Yarn cache is also reusable between arch and os.
- name: Restore yarn cache
uses: actions/cache@v3
id: yarn-download-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
yarn-download-cache-
# Invalidated on yarn.lock changes
- name: Restore yarn install state
id: yarn-install-state-cache
uses: actions/cache@v3
with:
path: .yarn/ci-cache/
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
- name: Install dependencies
run: yarn --frozen-lockfile
######################
# Patch SnoreToast to fix App ID - see https://github.com/th-ch/youtube-music/issues/479#issuecomment-965473559
- name: SnoreToast - parameters
id: snoretoast-params
if: startsWith(matrix.os, 'windows')
shell: bash
run: |
echo "::set-output name=version::v0.8.0"
echo "::set-output name=path::./vendor/snoretoast"
- name: SnoreToast - cache
id: snoretoast-cache
uses: actions/cache@v2
if: startsWith(matrix.os, 'windows')
with:
path: ${{ steps.snoretoast-params.outputs.path }}
key: snoretoast-${{ steps.snoretoast-params.outputs.version }}
- name: SnoreToast - compile
if: |
startsWith(matrix.os, 'windows') &&
steps.snoretoast-cache.outputs.cache-hit != 'true'
shell: bash
run: |
SNORETOAST_TAG="${{ steps.snoretoast-params.outputs.version }}"
echo "Compiling SnoreToast $SNORETOAST_TAG"
git config --global user.email "th-ch@users.noreply.github.com"
git config --global user.name "YouTube Music"
git clone -c advice.detachedHead=false --branch $SNORETOAST_TAG --depth 1 https://github.com/KDE/snoretoast.git ${{ steps.snoretoast-params.outputs.path }}
cd ${{ steps.snoretoast-params.outputs.path }}
# Apply https://github.com/KDE/snoretoast/pull/15/commits/c5faeceaf36f4b9fb27e5269990b716a25ecbe43
# Patch generated with `git format-patch -1 c5faeceaf36f4b9fb27e5269990b716a25ecbe43`
git am < ../snoretoast-patch/0001-Fix-activation-not-writing-to-pipe.patch
# Compile for win32
cmake -A Win32 -B build32
cmake --build build32 --config Release
# Compile for x64
cmake -A x64 -B build64
cmake --build build64 --config Release
- name: SnoreToast - overwrite with custom build
if: startsWith(matrix.os, 'windows')
shell: bash
run: |
# Override SnoreToast with the patched versions
cp ${{ steps.snoretoast-params.outputs.path }}/build32/bin/Release/snoretoast.exe ./node_modules/node-notifier/vendor/snoreToast/snoretoast-x86.exe
cp ${{ steps.snoretoast-params.outputs.path }}/build64/bin/Release/snoretoast.exe ./node_modules/node-notifier/vendor/snoreToast/snoretoast-x64.exe
# End of SnoreToast patch
######################
yarn install --immutable --inline-builds
env:
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
YARN_NM_MODE: "hardlinks-local" # Hardlinks-(local|global) reduces io / node_modules size
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
- name: Test
uses: GabrielBB/xvfb-action@v1
@ -96,23 +67,140 @@ jobs:
with:
run: yarn test
- name: Build on Mac
if: startsWith(matrix.os, 'macOS')
# Build and release if it's the main repository
- name: Build and release on Mac
if: startsWith(matrix.os, 'macOS') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
yarn run release:mac
- name: Build on Linux
if: startsWith(matrix.os, 'ubuntu')
- name: Build and release on Linux
if: startsWith(matrix.os, 'ubuntu') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
yarn run release:linux
- name: Build on Windows
if: startsWith(matrix.os, 'windows')
- name: Build and release on Windows
if: startsWith(matrix.os, 'windows') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
yarn run release:win
# Only build without release if it is a fork
- name: Build on Mac
if: startsWith(matrix.os, 'macOS') && github.repository != 'th-ch/youtube-music'
run: |
yarn run build:mac
- name: Build on Linux
if: startsWith(matrix.os, 'ubuntu') && github.repository != 'th-ch/youtube-music'
run: |
yarn run build:linux
- name: Build on Windows
if: startsWith(matrix.os, 'windows') && github.repository != 'th-ch/youtube-music'
run: |
yarn run build:win
release:
runs-on: ubuntu-latest
name: Release YouTube Music
if: github.repository == 'th-ch/youtube-music' && github.ref == 'refs/heads/master'
needs: build
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NodeJS
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: Expose yarn config as "$GITHUB_OUTPUT"
id: yarn-config
shell: bash
run: |
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
# Yarn cache is also reusable between arch and os.
- name: Restore yarn cache
uses: actions/cache@v3
id: yarn-download-cache
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-download-cache-
# Invalidated on yarn.lock changes
- name: Restore yarn install state
id: yarn-install-state-cache
uses: actions/cache@v3
with:
path: .yarn/ci-cache/
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
- name: Install dependencies
shell: bash
run: |
yarn install --immutable --inline-builds
env:
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
YARN_NM_MODE: "hardlinks-local" # Hardlinks-(local|global) reduces io / node_modules size
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
- name: Get version
run: |
echo "VERSION_TAG=v$(node -pe "require('./package.json').version")" >> $GITHUB_ENV
- name: Check if version already exists in tags
run: |
echo "VERSION_HASH=$(git rev-parse -q --verify 'refs/tags/${{ env.VERSION_TAG }}')" >> $GITHUB_ENV
echo "CHANGELOG_ANCHOR=$(echo $VERSION_TAG | sed -e 's/\.//g')" >> $GITHUB_ENV
- name: Fetch draft release
if: ${{ env.VERSION_HASH == '' }}
uses: cardinalby/git-get-release-action@v1
id: get_draft_release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
latest: true
draft: true
searchLimit: 1
- name: Publish Release (if it does not exist)
if: ${{ env.VERSION_HASH == '' }}
uses: irongut/EditRelease@v1.2.0
with:
token: ${{ secrets.GH_TOKEN }}
id: ${{ steps.get_draft_release.outputs.id }}
draft: false
prerelease: false
replacename: true
name: ${{ env.VERSION_TAG }}
replacebody: true
body: |
See [changelog](https://github.com/th-ch/youtube-music/blob/master/changelog.md#${{ env.CHANGELOG_ANCHOR }}) for the list of updates and the full diff.
Thanks to all contributors! 🏅
- name: Update changelog
if: ${{ env.VERSION_HASH == '' }}
run: |
yarn changelog
- name: Commit changelog
if: ${{ env.VERSION_HASH == '' }}
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Update changelog for ${{ env.VERSION_TAG }}
file_pattern: "changelog.md"
commit_user_name: CI
commit_user_email: th-ch@users.noreply.github.com

20
.github/workflows/dependency-review.yml vendored Normal file
View File

@ -0,0 +1,20 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
name: "Dependency Review"
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v3
- name: "Dependency Review"
uses: actions/dependency-review-action@v3

26
.github/workflows/winget-submission.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Submit to Windows Package Manager Community Repository
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag_name:
description: "Specific tag name"
required: true
type: string
jobs:
winget:
name: Publish winget package
runs-on: windows-latest
steps:
- name: Submit package to Windows Package Manager Community Repository
uses: vedantmgoyal2009/winget-releaser@v2
with:
identifier: th-ch.YouTubeMusic
installers-regex: '^YouTube-Music-Setup-[\d\.]+\.exe$'
version: ${{ inputs.tag_name || github.event.release.tag_name }}
release-tag: ${{ inputs.tag_name || github.event.release.tag_name }}
token: ${{ secrets.WINGET_ACC_TOKEN }}
fork-user: youtube-music-winget

10
.gitignore vendored
View File

@ -1,5 +1,13 @@
node_modules
/dist
/assets/generated
electron-builder.yml
.vscode/settings.json
.idea
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -0,0 +1,24 @@
diff --git a/index.js b/index.js
index c8f2fd4467c11b484fe654f7f250e2ba37e8100d..c9ae1ed3d3c7683b14dfe0eee801f5a07585d2aa 100644
--- a/index.js
+++ b/index.js
@@ -5,7 +5,16 @@ if (typeof electron === 'string') {
throw new TypeError('Not running in an Electron environment!');
}
-const isEnvSet = 'ELECTRON_IS_DEV' in process.env;
-const getFromEnv = Number.parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
+const isDev = () => {
+ if ('ELECTRON_IS_DEV' in process.env) {
+ return Number.parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
+ }
-module.exports = isEnvSet ? getFromEnv : !electron.app.isPackaged;
+ if (process.type === 'browser') {
+ return !electron.app.isPackaged;
+ }
+
+ return 'npm_package_name' in process.env;
+};
+
+module.exports = isDev();

View File

@ -0,0 +1,9 @@
/* eslint-disable */
//prettier-ignore
module.exports = {
name: "@yarnpkg/plugin-after-install",
factory: function (require) {
var plugin=(()=>{var g=Object.create,r=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var C=Object.getOwnPropertyNames;var k=Object.getPrototypeOf,y=Object.prototype.hasOwnProperty;var I=t=>r(t,"__esModule",{value:!0});var i=t=>{if(typeof require!="undefined")return require(t);throw new Error('Dynamic require of "'+t+'" is not supported')};var h=(t,o)=>{for(var e in o)r(t,e,{get:o[e],enumerable:!0})},w=(t,o,e)=>{if(o&&typeof o=="object"||typeof o=="function")for(let n of C(o))!y.call(t,n)&&n!=="default"&&r(t,n,{get:()=>o[n],enumerable:!(e=x(o,n))||e.enumerable});return t},a=t=>w(I(r(t!=null?g(k(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t);var j={};h(j,{default:()=>b});var c=a(i("@yarnpkg/core")),m={afterInstall:{description:"Hook that will always run after install",type:c.SettingsType.STRING,default:""}};var u=a(i("clipanion")),d=a(i("@yarnpkg/core"));var p=a(i("@yarnpkg/shell")),l=async(t,o)=>{var f;let e=t.get("afterInstall"),n=!!((f=t.projectCwd)==null?void 0:f.endsWith(`dlx-${process.pid}`));return e&&!n?(o&&console.log("Running `afterInstall` hook..."),(0,p.execute)(e,[],{cwd:t.projectCwd||void 0})):0};var s=class extends u.Command{async execute(){let o=await d.Configuration.find(this.context.cwd,this.context.plugins);return l(o,!1)}};s.paths=[["after-install"]];var P={configuration:m,commands:[s],hooks:{afterAllInstalled:async t=>{if(await l(t.configuration,!0))throw new Error("The `afterInstall` hook failed, see output above.")}}},b=P;return j;})();
return plugin;
}
};

873
.yarn/releases/yarn-3.4.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

9
.yarnrc.yml Normal file
View File

@ -0,0 +1,9 @@
afterInstall: yarn postinstall
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-after-install.cjs
spec: "https://raw.githubusercontent.com/mhassan1/yarn-plugin-after-install/v0.3.1/bundles/@yarnpkg/plugin-after-install.js"
yarnPath: .yarn/releases/yarn-3.4.1.cjs

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

View File

Before

Width:  |  Height:  |  Size: 192 B

After

Width:  |  Height:  |  Size: 192 B

View File

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

@ -2,8 +2,37 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v1.19.0](https://github.com/th-ch/youtube-music/compare/v1.18.0...v1.19.0)
- Automatic release by CI when version is updated [`#936`](https://github.com/th-ch/youtube-music/pull/936)
- Center toggle of video-toggle [`#894`](https://github.com/th-ch/youtube-music/pull/894)
- Load plugins as soon as the window is created [`#890`](https://github.com/th-ch/youtube-music/pull/890)
- Bump qs from 6.5.2 to 6.5.3 [`#913`](https://github.com/th-ch/youtube-music/pull/913)
- [Snyk] Upgrade custom-electron-titlebar from 4.1.1 to 4.1.2 [`#900`](https://github.com/th-ch/youtube-music/pull/900)
- Add option in skip-silences plugin to only skip at the beginning [`#931`](https://github.com/th-ch/youtube-music/pull/931)
- Replace rimraf by del-cli [`#932`](https://github.com/th-ch/youtube-music/pull/932)
- docs: Added winget install instructions [`#873`](https://github.com/th-ch/youtube-music/pull/873)
- [Snyk] Upgrade async-mutex from 0.3.2 to 0.4.0 [`#855`](https://github.com/th-ch/youtube-music/pull/855)
- [Snyk] Upgrade @cliqz/adblocker-electron from 1.25.0 to 1.25.1 [`#856`](https://github.com/th-ch/youtube-music/pull/856)
- [Snyk] Upgrade custom-electron-titlebar from 4.1.0 to 4.1.1 [`#865`](https://github.com/th-ch/youtube-music/pull/865)
- [Snyk] Upgrade @ffmpeg/ffmpeg from 0.11.5 to 0.11.6 [`#876`](https://github.com/th-ch/youtube-music/pull/876)
- Discord Plugin RPC Fix [`#888`](https://github.com/th-ch/youtube-music/pull/888)
- Bump FFMpeg [`#854`](https://github.com/th-ch/youtube-music/pull/854)
- [Snyk] Upgrade @cliqz/adblocker-electron from 1.23.8 to 1.23.9 [`#823`](https://github.com/th-ch/youtube-music/pull/823)
- [Snyk] Upgrade electron-store from 8.0.2 to 8.1.0 [`#801`](https://github.com/th-ch/youtube-music/pull/801)
- proposal: Adding an option to hide duration before the song ends [`#802`](https://github.com/th-ch/youtube-music/pull/802)
- [Snyk] Security upgrade node-fetch from 2.6.7 to 3.2.10 [`#790`](https://github.com/th-ch/youtube-music/pull/790)
- Update README.md with a new theme repo [`#807`](https://github.com/th-ch/youtube-music/pull/807)
- Fix likes on touchbar (they were inverted) [`#822`](https://github.com/th-ch/youtube-music/pull/822)
- Add Scoop install directions for Windows 🪟 [`#839`](https://github.com/th-ch/youtube-music/pull/839)
- Bump version and change release type when publishing a new version [`31ab27c`](https://github.com/th-ch/youtube-music/commit/31ab27c39ff6319116a6514d952eed1f02dd45fd)
- Lock node-fetch to v2 for commonJS [`c9f610f`](https://github.com/th-ch/youtube-music/commit/c9f610f7fcfcce1317338364045ab0e1bf4249a4)
- fix: upgrade @cliqz/adblocker-electron from 1.25.0 to 1.25.1 [`762ef4e`](https://github.com/th-ch/youtube-music/commit/762ef4eede29b53aae912b3b50a1ca53f6765c53)
#### [v1.18.0](https://github.com/th-ch/youtube-music/compare/v1.17.0...v1.18.0)
> 5 September 2022
- Bump ytdl-core (bug fix) [`#816`](https://github.com/th-ch/youtube-music/pull/816)
- Bump electron and fix tests in CI [`#813`](https://github.com/th-ch/youtube-music/pull/813)
- Allow user to pass custom CSS file [`#800`](https://github.com/th-ch/youtube-music/pull/800)

View File

@ -16,6 +16,7 @@ const defaultConfig = {
autoResetAppCache: false,
resumeOnStart: true,
proxy: "",
startingPage: "",
},
plugins: {
// Enabled plugins
@ -46,15 +47,22 @@ const defaultConfig = {
},
discord: {
enabled: false,
autoReconnect: true, // if enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
activityTimoutEnabled: true, // if enabled, the discord rich presence gets cleared when music paused after the time specified below
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
listenAlong: true, // add a "listen along" button to rich presence
hideDurationLeft: false, // hides the start and end time of the song to rich presence
},
notifications: {
enabled: false,
unpauseNotification: false,
urgency: "normal", //has effect only on Linux
interactive: false //has effect only on Windows
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // see plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false
},
"precise-volume": {
enabled: false,
@ -90,6 +98,86 @@ const defaultConfig = {
"saveSize": false,
"hotkey": "P"
},
"captions-selector": {
enabled: false,
disableCaptions: false
},
"skip-silences": {
onlySkipBeginning: false,
},
"crossfade": {
enabled: false,
fadeInDuration: 1500, // ms
fadeOutDuration: 5000, // ms
secondsBeforeEnd: 10, // s
fadeScaling: "linear", // 'linear', 'logarithmic' or a positive number in dB
},
visualizer: {
enabled: false,
type: "butterchurn",
// Config per visualizer
butterchurn: {
preset: "martin [shadow harlequins shape code] - fata morgana",
renderingFrequencyInMs: 500,
blendTimeInSeconds: 2.7,
},
vudio: {
effect: "lighting",
accuracy: 128,
lighting: {
maxHeight: 160,
maxSize: 12,
lineWidth: 1,
color: "#49f3f7",
shadowBlur: 2,
shadowColor: "rgba(244,244,244,.5)",
fadeSide: true,
prettify: false,
horizontalAlign: "center",
verticalAlign: "middle",
dottify: true,
},
},
wave: {
animations: [
{
type: "Cubes",
config: {
bottom: true,
count: 30,
cubeHeight: 5,
fillColor: { gradient: ["#FAD961", "#F76B1C"] },
lineColor: "rgba(0,0,0,0)",
radius: 20,
},
},
{
type: "Cubes",
config: {
top: true,
count: 12,
cubeHeight: 5,
fillColor: { gradient: ["#FAD961", "#F76B1C"] },
lineColor: "rgba(0,0,0,0)",
radius: 10,
},
},
{
type: "Circles",
config: {
lineColor: {
gradient: ["#FAD961", "#FAD961", "#F76B1C"],
rotate: 90,
},
lineWidth: 4,
diameter: 20,
count: 10,
frequencyBand: "base",
},
},
],
},
},
},
};

205
config/dynamic.js Normal file
View File

@ -0,0 +1,205 @@
const { ipcRenderer, ipcMain } = require("electron");
const defaultConfig = require("./defaults");
const { getOptions, setOptions, setMenuOptions } = require("./plugins");
const { sendToFront } = require("../providers/app-controls");
const activePlugins = {};
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
module.exports.getActivePlugins =
process.type === "renderer"
? async () => ipcRenderer.invoke("get-active-plugins")
: () => activePlugins;
if (process.type === "browser") {
ipcMain.handle("get-active-plugins", this.getActivePlugins);
}
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
module.exports.isActive =
process.type === "renderer"
? async (plugin) =>
plugin in (await ipcRenderer.invoke("get-active-plugins"))
: (plugin) => plugin in activePlugins;
/**
* This class is used to create a dynamic synced config for plugins.
*
* [!IMPORTANT!]
* The methods are **sync** in the main process and **async** in the renderer process.
*
* @param {string} name - The name of the plugin.
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
*
* @example
* const { PluginConfig } = require("../../config/dynamic");
* const config = new PluginConfig("plugin-name", { enableFront: true });
* module.exports = { ...config };
*
* // or
*
* module.exports = (win, options) => {
* const config = new PluginConfig("plugin-name", {
* enableFront: true,
* initialOptions: options,
* });
* setupMyPlugin(win, config);
* };
*/
module.exports.PluginConfig = class PluginConfig {
#name;
#config;
#defaultConfig;
#enableFront;
#subscribers = {};
#allSubscribers = [];
constructor(name, { enableFront = false, initialOptions = undefined } = {}) {
const pluginDefaultConfig = defaultConfig.plugins[name] || {};
const pluginConfig = initialOptions || getOptions(name) || {};
this.#name = name;
this.#enableFront = enableFront;
this.#defaultConfig = pluginDefaultConfig;
this.#config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.#enableFront) {
this.#setupFront();
}
activePlugins[name] = this;
}
get = (option) => {
return this.#config[option];
};
set = (option, value) => {
this.#config[option] = value;
this.#onChange(option);
this.#save();
};
toggle = (option) => {
this.#config[option] = !this.#config[option];
this.#onChange(option);
this.#save();
};
getAll = () => {
return { ...this.#config };
};
setAll = (options) => {
if (!options || typeof options !== "object")
throw new Error("Options must be an object.");
let changed = false;
for (const [key, val] of Object.entries(options)) {
if (this.#config[key] !== val) {
this.#config[key] = val;
this.#onChange(key, false);
changed = true;
}
}
if (changed) this.#allSubscribers.forEach((fn) => fn(this.#config));
this.#save();
};
getDefaultConfig = () => {
return this.#defaultConfig;
};
/**
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
*
* Used for options that require a restart to take effect.
*/
setAndMaybeRestart = (option, value) => {
this.#config[option] = value;
setMenuOptions(this.#name, this.#config);
this.#onChange(option);
};
subscribe = (valueName, fn) => {
this.#subscribers[valueName] = fn;
};
subscribeAll = (fn) => {
this.#allSubscribers.push(fn);
};
/** Called only from back */
#save() {
setOptions(this.#name, this.#config);
}
#onChange(valueName, single = true) {
this.#subscribers[valueName]?.(this.#config[valueName]);
if (single) this.#allSubscribers.forEach((fn) => fn(this.#config));
}
#setupFront() {
const ignoredMethods = ["subscribe", "subscribeAll"];
if (process.type === "renderer") {
for (const [fnName, fn] of Object.entries(this)) {
if (typeof fn !== "function" || fn.name in ignoredMethods) return;
this[fnName] = async (...args) => {
return await ipcRenderer.invoke(
`${this.#name}-config-${fnName}`,
...args,
);
};
this.subscribe = (valueName, fn) => {
if (valueName in this.#subscribers) {
console.error(`Already subscribed to ${valueName}`);
}
this.#subscribers[valueName] = fn;
ipcRenderer.on(
`${this.#name}-config-changed-${valueName}`,
(_, value) => {
fn(value);
},
);
ipcRenderer.send(`${this.#name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn) => {
ipcRenderer.on(`${this.#name}-config-changed`, (_, value) => {
fn(value);
});
ipcRenderer.send(`${this.#name}-config-subscribe-all`);
};
}
} else if (process.type === "browser") {
for (const [fnName, fn] of Object.entries(this)) {
if (typeof fn !== "function" || fn.name in ignoredMethods) return;
ipcMain.handle(`${this.#name}-config-${fnName}`, (_, ...args) => {
return fn(...args);
});
}
ipcMain.on(`${this.#name}-config-subscribe`, (_, valueName) => {
this.subscribe(valueName, (value) => {
sendToFront(`${this.#name}-config-changed-${valueName}`, value);
});
});
ipcMain.on(`${this.#name}-config-subscribe-all`, () => {
this.subscribeAll((value) => {
sendToFront(`${this.#name}-config-changed`, value);
});
});
}
}
};

View File

@ -9,6 +9,22 @@ const setDefaultPluginOptions = (store, plugin) => {
}
const migrations = {
">=1.20.0": (store) => {
setDefaultPluginOptions(store, "visualizer");
if (store.get("plugins.notifications.toastStyle") === undefined) {
const pluginOptions = store.get("plugins.notifications") || {};
store.set("plugins.notifications", {
...defaults.plugins.notifications,
...pluginOptions,
});
}
if (store.get("options.ForceShowLikeButtons")) {
store.delete("options.ForceShowLikeButtons");
store.set("options.likeButtons", 'force');
}
},
">=1.17.0": (store) => {
setDefaultPluginOptions(store, "picture-in-picture");

View File

@ -14,6 +14,7 @@ const { isTesting } = require("./utils/testing");
const { setUpTray } = require("./tray");
const { setupSongInfo } = require("./providers/song-info");
const { setupAppControls, restart } = require("./providers/app-controls");
const { APP_PROTOCOL, setupProtocolHandler, handleProtocol } = require("./providers/protocol-handler");
// Catch errors and log them
unhandled({
@ -29,23 +30,10 @@ const app = electron.app;
let mainWindow;
autoUpdater.autoDownload = false;
if(config.get("options.singleInstanceLock")){
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.exit();
app.commandLine.appendSwitch(
"js-flags",
// WebAssembly flags
"--experimental-wasm-threads"
);
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); // Required for downloader
app.allowRendererProcessReuse = true; // https://github.com/electron/electron/issues/18397
if (config.get("options.disableHardwareAcceleration")) {
@ -82,6 +70,7 @@ function onClosed() {
mainWindow = null;
}
/** @param {Electron.BrowserWindow} win */
function loadPlugins(win) {
injectCSS(win.webContents, path.join(__dirname, "youtube-music.css"));
// Load user CSS
@ -152,6 +141,7 @@ function createMainWindow() {
: "default",
autoHideMenuBar: config.get("options.hideMenu"),
});
loadPlugins(win);
if (windowPosition) {
const { x, y } = windowPosition;
@ -188,7 +178,7 @@ function createMainWindow() {
win.webContents.loadURL(urlToLoad);
win.on("closed", onClosed);
const setPiPOptions = config.plugins.isEnabled("picture-in-picture")
const setPiPOptions = config.plugins.isEnabled("picture-in-picture")
? (key, value) => require("./plugins/picture-in-picture/back").setOptions({ [key]: value })
: () => {};
@ -284,7 +274,6 @@ app.once("browser-window-created", (event, win) => {
}
setupSongInfo(win);
loadPlugins(win);
setupAppControls();
win.webContents.on("did-fail-load", (
@ -317,17 +306,6 @@ app.once("browser-window-created", (event, win) => {
win.webContents.on("will-prevent-unload", (event) => {
event.preventDefault();
});
win.webContents.on(
"new-window",
(e, url, frameName, disposition, options) => {
// hook on new opened window
// at now new window in mainWindow renderer process.
// Also, this will automatically get an option `nodeIntegration=false`(not override to true, like in iframe's) - like in regular browsers
options.webPreferences.affinity = "main-window";
}
);
});
app.on("window-all-closed", () => {
@ -372,7 +350,10 @@ app.on("ready", () => {
const shortcutPath = path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "YouTube Music.lnk");
try { // check if shortcut is registered and valid
const shortcutDetails = electron.shell.readShortcutLink(shortcutPath); // throw error if doesn't exist yet
if (shortcutDetails.target !== appLocation || shortcutDetails.appUserModelId !== appID) {
if (
shortcutDetails.target !== appLocation ||
shortcutDetails.appUserModelId !== appID
) {
throw "needUpdate";
}
} catch (error) { // if not valid -> Register shortcut
@ -381,9 +362,9 @@ app.on("ready", () => {
error === "needUpdate" ? "update" : "create",
{
target: appLocation,
cwd: appLocation.slice(0, appLocation.lastIndexOf(path.sep)),
cwd: path.dirname(appLocation),
description: "YouTube Music Desktop App - including custom plugins",
appUserModelId: appID
appUserModelId: appID,
}
);
}
@ -394,6 +375,24 @@ app.on("ready", () => {
setApplicationMenu(mainWindow);
setUpTray(app, mainWindow);
setupProtocolHandler(mainWindow);
app.on('second-instance', (_event, commandLine, _workingDirectory) => {
const uri = `${APP_PROTOCOL}://`;
const protocolArgv = commandLine.find(arg => arg.startsWith(uri));
if (protocolArgv) {
const lastIndex = protocolArgv.endsWith("/") ? -1 : undefined;
const command = protocolArgv.slice(uri.length, lastIndex);
if (is.dev()) console.debug(`Received command over protocol: "${command}"`);
handleProtocol(command);
return;
}
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
// Autostart at login
app.setLoginItemSettings({
openAtLogin: config.get("options.startAtLogin"),
@ -489,13 +488,12 @@ function removeContentSecurityPolicy(
// Custom listener to tweak the content security policy
session.webRequest.onHeadersReceived(function (details, callback) {
if (
!details.responseHeaders["content-security-policy-report-only"] &&
!details.responseHeaders["content-security-policy"]
)
return callback({ cancel: false });
details.responseHeaders ??= {}
// Remove the content security policy
delete details.responseHeaders["content-security-policy-report-only"];
delete details.responseHeaders["content-security-policy"];
callback({ cancel: false, responseHeaders: details.responseHeaders });
});

View File

@ -1,6 +0,0 @@
module.exports = {
globals: {
__APP__: undefined, // A different app will be launched in each test environment
},
testTimeout: 30000, // 30s
};

75
menu.js
View File

@ -1,12 +1,13 @@
const { existsSync } = require("fs");
const path = require("path");
const { app, Menu, dialog } = require("electron");
const { app, clipboard, Menu, dialog } = require("electron");
const is = require("electron-is");
const { restart } = require("./providers/app-controls");
const { getAllPlugins } = require("./plugins/utils");
const config = require("./config");
const { startingPages } = require("./providers/extracted-data");
const prompt = require("custom-electron-prompt");
const promptOptions = require("./providers/prompt-options");
@ -44,12 +45,16 @@ const mainMenuTemplate = (win) => {
...getAllPlugins().map((plugin) => {
const pluginPath = path.join(__dirname, "plugins", plugin, "menu.js")
if (existsSync(pluginPath)) {
let pluginLabel = plugin;
if (pluginLabel === "crossfade") {
pluginLabel = "crossfade [beta]";
}
if (!config.plugins.isEnabled(plugin)) {
return pluginEnabledMenu(plugin, "", true, refreshMenu);
return pluginEnabledMenu(plugin, pluginLabel, true, refreshMenu);
}
const getPluginMenu = require(pluginPath);
return {
label: plugin,
label: pluginLabel,
submenu: [
pluginEnabledMenu(plugin, "Enabled", true, refreshMenu),
{ type: "separator" },
@ -57,7 +62,6 @@ const mainMenuTemplate = (win) => {
],
};
}
return pluginEnabledMenu(plugin);
}),
],
@ -81,6 +85,17 @@ const mainMenuTemplate = (win) => {
config.setMenuOption("options.resumeOnStart", item.checked);
},
},
{
label: 'Starting page',
submenu: Object.keys(startingPages).map((name) => ({
label: name,
type: 'radio',
checked: config.get('options.startingPage') === name,
click: () => {
config.set('options.startingPage', name);
},
}))
},
{
label: "Visual Tweaks",
submenu: [
@ -93,12 +108,33 @@ const mainMenuTemplate = (win) => {
},
},
{
label: "Force show like buttons",
type: "checkbox",
checked: config.get("options.ForceShowLikeButtons"),
click: (item) => {
config.set("options.ForceShowLikeButtons", item.checked);
},
label: "Like buttons",
submenu: [
{
label: "Default",
type: "radio",
checked: !config.get("options.likeButtons"),
click: () => {
config.set("options.likeButtons", '');
},
},
{
label: "Force show",
type: "radio",
checked: config.get("options.likeButtons") === 'force',
click: () => {
config.set("options.likeButtons", 'force');
}
},
{
label: "Hide",
type: "radio",
checked: config.get("options.likeButtons") === 'hide',
click: () => {
config.set("options.likeButtons", 'hide');
}
},
],
},
{
label: "Theme",
@ -133,14 +169,12 @@ const mainMenuTemplate = (win) => {
{
label: "Single instance lock",
type: "checkbox",
checked: config.get("options.singleInstanceLock"),
checked: true,
click: (item) => {
config.setMenuOption("options.singleInstanceLock", item.checked);
if (item.checked && !app.hasSingleInstanceLock()) {
app.requestSingleInstanceLock();
} else if (!item.checked && app.hasSingleInstanceLock()) {
if (!item.checked && app.hasSingleInstanceLock())
app.releaseSingleInstanceLock();
}
else if (item.checked && !app.hasSingleInstanceLock())
app.requestSingleInstanceLock();
},
},
{
@ -163,7 +197,7 @@ const mainMenuTemplate = (win) => {
if (item.checked && !config.get("options.hideMenuWarned")) {
dialog.showMessageBox(win, {
type: 'info', title: 'Hide Menu Enabled',
message: "Menu will be hidden on next launch, use 'Alt' to show it (or 'Escape' if using in-app-menu)"
message: "Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)"
});
}
},
@ -329,6 +363,13 @@ const mainMenuTemplate = (win) => {
}
},
},
{
label: "Copy current URL",
click: () => {
const currentURL = win.webContents.getURL();
clipboard.writeText(currentURL);
},
},
{
label: "Restart App",
click: restart

View File

@ -1,7 +1,7 @@
{
"name": "youtube-music",
"productName": "YouTube Music",
"version": "1.18.0",
"version": "1.20.0",
"description": "YouTube Music Desktop App - including custom plugins",
"license": "MIT",
"repository": "th-ch/youtube-music",
@ -35,8 +35,20 @@
"!plugins/touchbar${/*}"
],
"target": [
"nsis",
"portable"
{
"target": "nsis",
"arch": [
"x64",
"arm64"
]
},
{
"target": "portable",
"arch": [
"x64",
"arm64"
]
}
]
},
"nsis": {
@ -71,70 +83,74 @@
"test:debug": "DEBUG=pw:browser* playwright test",
"start": "electron .",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron .",
"icon": "rimraf assets/generated && electron-icon-maker --input=assets/youtube-music.png --output=assets/generated",
"generate:package": "node utils/generate-package-json.js",
"postinstall": "yarn run icon && yarn run plugins",
"clean": "rimraf dist",
"build": "yarn run clean && electron-builder --win --mac --linux",
"build:linux": "yarn run clean && electron-builder --linux",
"build:mac": "yarn run clean && electron-builder --mac dmg:x64",
"build:mac:arm64": "yarn run clean && electron-builder --mac dmg:arm64",
"build:win": "yarn run clean && electron-builder --win",
"postinstall": "yarn run plugins",
"clean": "del-cli dist",
"build": "yarn run clean && electron-builder --win --mac --linux -p never",
"build:linux": "yarn run clean && electron-builder --linux -p never",
"build:mac": "yarn run clean && electron-builder --mac dmg:x64 -p never",
"build:mac:arm64": "yarn run clean && electron-builder --mac dmg:arm64 -p never",
"build:win": "yarn run clean && electron-builder --win -p never",
"lint": "xo",
"changelog": "auto-changelog",
"plugins": "yarn run plugin:adblocker",
"plugin:adblocker": "rimraf plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
"plugins": "yarn run plugin:adblocker && yarn run plugin:bypass-age-restrictions",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && yarn run generate:package simple-youtube-age-restriction-bypass",
"release:linux": "yarn run clean && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "yarn run clean && electron-builder --mac -p always",
"release:win": "yarn run clean && electron-builder --win -p always"
},
"engines": {
"node": ">=14.0.0",
"npm": "Please use yarn and not npm"
"node": ">=16.0.0",
"npm": "Please use yarn instead"
},
"dependencies": {
"@cliqz/adblocker-electron": "^1.23.8",
"@ffmpeg/core": "^0.10.0",
"@ffmpeg/ffmpeg": "^0.10.1",
"Simple-YouTube-Age-Restriction-Bypass": "https://gitpkg.now.sh/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/dist?v2.4.6",
"async-mutex": "^0.3.2",
"browser-id3-writer": "^4.4.0",
"chokidar": "^3.5.3",
"custom-electron-prompt": "^1.5.0",
"custom-electron-titlebar": "^4.1.0",
"discord-rpc": "^4.0.1",
"@cliqz/adblocker-electron": "^1.26.5",
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@foobar404/wave": "^2.0.4",
"@xhayper/discord-rpc": "^1.0.16",
"async-mutex": "^0.4.0",
"browser-id3-writer": "^5.0.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"custom-electron-prompt": "^1.5.7",
"custom-electron-titlebar": "^4.1.6",
"electron-better-web-request": "^1.0.1",
"electron-debug": "^3.2.0",
"electron-is": "^3.0.0",
"electron-localshortcut": "^3.2.1",
"electron-store": "^8.0.2",
"electron-store": "^8.1.0",
"electron-unhandled": "^4.0.1",
"electron-updater": "^4.6.3",
"electron-updater": "^5.3.0",
"filenamify": "^4.3.0",
"hark": "^1.2.3",
"html-to-text": "^8.2.1",
"howler": "^2.2.3",
"html-to-text": "^9.0.5",
"keyboardevent-from-electron-accelerator": "^2.0.0",
"keyboardevents-areequal": "^0.2.2",
"md5": "^2.3.0",
"mpris-service": "^2.1.2",
"node-fetch": "^2.6.7",
"node-notifier": "^10.0.1",
"ytdl-core": "^4.11.1",
"node-fetch": "^2.6.9",
"simple-youtube-age-restriction-bypass": "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4",
"vudio": "^2.1.1",
"youtubei.js": "^4.3.0",
"ytpl": "^2.3.0"
},
"devDependencies": {
"@playwright/test": "^1.25.1",
"auto-changelog": "^2.4.0",
"electron": "^20.1.1",
"electron-builder": "^23.0.3",
"electron-devtools-installer": "^3.1.1",
"electron-icon-maker": "0.0.5",
"playwright": "^1.25.1",
"rimraf": "^3.0.2",
"xo": "^0.45.0"
},
"resolutions": {
"glob-parent": "5.1.2",
"minimist": "1.2.6",
"yargs-parser": "18.1.3"
"xml2js": "^0.5.0",
"@electron/universal": "^1.3.4",
"electron-is-dev": "patch:electron-is-dev@npm%3A2.0.0#./.yarn/patches/electron-is-dev-npm-2.0.0-9d41637d91.patch"
},
"devDependencies": {
"@playwright/test": "^1.29.2",
"auto-changelog": "^2.4.0",
"del-cli": "^5.0.0",
"electron": "^22.3.6",
"electron-builder": "^23.6.0",
"electron-devtools-installer": "^3.2.0",
"node-gyp": "^9.3.1",
"playwright": "^1.29.2",
"xo": "^0.53.1"
},
"auto-changelog": {
"hideCredit": true,
@ -157,5 +173,6 @@
}
]
}
}
},
"packageManager": "yarn@3.4.1"
}

View File

@ -1,8 +1,13 @@
const { loadAdBlockerEngine } = require("./blocker");
module.exports = (win, options) =>
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists
);
const config = require("./config");
module.exports = async (win, options) => {
if (await config.shouldUseBlocklists()) {
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

View File

@ -0,0 +1,13 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("adblocker", { enableFront: true });
const blockers = {
WithBlocklists: "With blocklists",
InPlayer: "In player",
};
const shouldUseBlocklists = async () =>
(await config.get("blocker")) !== blockers.InPlayer;
module.exports = { shouldUseBlocklists, blockers, ...config };

289
plugins/adblocker/inject.js Normal file
View File

@ -0,0 +1,289 @@
// Source: https://addons.mozilla.org/en-US/firefox/addon/adblock-for-youtube/
// https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-US%2Ffirefox%2Faddon%2Fadblock-for-youtube%2F
/*
Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/
{
let pruner = function (o) {
delete o.playerAds;
delete o.adPlacements;
//
if (o.playerResponse) {
delete o.playerResponse.playerAds;
delete o.playerResponse.adPlacements;
}
//
return o;
};
JSON.parse = new Proxy(JSON.parse, {
apply: function () {
return pruner(Reflect.apply(...arguments));
},
});
Response.prototype.json = new Proxy(Response.prototype.json, {
apply: function () {
return Reflect.apply(...arguments).then((o) => pruner(o));
},
});
}
(function () {
let cValue = "undefined";
const chain = "playerResponse.adPlacements";
const thisScript = document.currentScript;
//
if (cValue === "null") cValue = null;
else if (cValue === "''") cValue = "";
else if (cValue === "true") cValue = true;
else if (cValue === "false") cValue = false;
else if (cValue === "undefined") cValue = undefined;
else if (cValue === "noopFunc") cValue = function () {};
else if (cValue === "trueFunc")
cValue = function () {
return true;
};
else if (cValue === "falseFunc")
cValue = function () {
return false;
};
else if (/^\d+$/.test(cValue)) {
cValue = parseFloat(cValue);
//
if (isNaN(cValue)) return;
if (Math.abs(cValue) > 0x7fff) return;
} else {
return;
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) return true;
aborted =
v !== undefined &&
v !== null &&
cValue !== undefined &&
cValue !== null &&
typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let prevGetter, prevSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) return;
if (odesc.get instanceof Function) prevGetter = odesc.get;
if (odesc.set instanceof Function) prevSetter = odesc.set;
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (prevGetter !== undefined) {
prevGetter();
}
//
return handler.getter();
},
set(a) {
if (prevSetter !== undefined) {
prevSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf(".");
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter: function () {
return document.currentScript === thisScript ? this.v : cValue;
},
setter: function (a) {
if (mustAbort(a) === false) return;
cValue = a;
},
init: function (v) {
if (mustAbort(v)) return false;
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === "object" && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter: function () {
return this.v;
},
setter: function (a) {
this.v = a;
if (a instanceof Object) trapChain(a, chain);
},
init: function (v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();
(function () {
let cValue = "undefined";
const thisScript = document.currentScript;
const chain = "ytInitialPlayerResponse.adPlacements";
//
if (cValue === "null") cValue = null;
else if (cValue === "''") cValue = "";
else if (cValue === "true") cValue = true;
else if (cValue === "false") cValue = false;
else if (cValue === "undefined") cValue = undefined;
else if (cValue === "noopFunc") cValue = function () {};
else if (cValue === "trueFunc")
cValue = function () {
return true;
};
else if (cValue === "falseFunc")
cValue = function () {
return false;
};
else if (/^\d+$/.test(cValue)) {
cValue = parseFloat(cValue);
//
if (isNaN(cValue)) return;
if (Math.abs(cValue) > 0x7fff) return;
} else {
return;
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) return true;
aborted =
v !== undefined &&
v !== null &&
cValue !== undefined &&
cValue !== null &&
typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let prevGetter, prevSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) return;
if (odesc.get instanceof Function) prevGetter = odesc.get;
if (odesc.set instanceof Function) prevSetter = odesc.set;
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (prevGetter !== undefined) {
prevGetter();
}
//
return handler.getter();
},
set(a) {
if (prevSetter !== undefined) {
prevSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf(".");
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter: function () {
return document.currentScript === thisScript ? this.v : cValue;
},
setter: function (a) {
if (mustAbort(a) === false) return;
cValue = a;
},
init: function (v) {
if (mustAbort(v)) return false;
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === "object" && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter: function () {
return this.v;
},
setter: function (a) {
this.v = a;
if (a instanceof Object) trapChain(a, chain);
},
init: function (v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();

15
plugins/adblocker/menu.js Normal file
View File

@ -0,0 +1,15 @@
const config = require("./config");
module.exports = () => [
{
label: "Blocker",
submenu: Object.values(config.blockers).map((blocker) => ({
label: blocker,
type: "radio",
checked: (config.get("blocker") || config.blockers.WithBlocklists) === blocker,
click: () => {
config.set("blocker", blocker);
},
})),
},
];

View File

@ -1,4 +1,10 @@
module.exports = () => {
// Preload adblocker to inject scripts/styles
require("@cliqz/adblocker-electron-preload/dist/preload.cjs");
const config = require("./config");
module.exports = async () => {
if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
require("@cliqz/adblocker-electron-preload");
} else if ((await config.get("blocker")) === config.blockers.InPlayer) {
require("./inject");
}
};

View File

@ -1,5 +1,5 @@
const applyCompressor = () => {
const audioContext = new AudioContext();
const applyCompressor = (e) => {
const audioContext = e.detail.audioContext;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
@ -8,10 +8,12 @@ const applyCompressor = () => {
compressor.attack.value = 0;
compressor.release.value = 0.25;
const source = audioContext.createMediaElementSource(document.querySelector("video"));
source.connect(compressor);
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
};
module.exports = () => document.addEventListener('apiLoaded', applyCompressor, { once: true, passive: true });
module.exports = () =>
document.addEventListener("audioCanPlay", applyCompressor, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -1,4 +1,4 @@
module.exports = () => {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
require("simple-youtube-age-restriction-bypass/Simple-YouTube-Age-Restriction-Bypass.user.js");
require("simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js");
};

View File

@ -0,0 +1,21 @@
const { ipcMain } = require("electron");
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
module.exports = (win) => {
ipcMain.handle("captionsSelector", async (_, captionLabels, currentIndex) => {
return await prompt(
{
title: "Choose Caption",
label: `Current Caption: ${captionLabels[currentIndex] || "None"}`,
type: "select",
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win
);
});
};

View File

@ -0,0 +1,3 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("captions-selector", { enableFront: true });
module.exports = { ...config };

View File

@ -0,0 +1,77 @@
const { ElementFromFile, templatePath } = require("../utils");
const { ipcRenderer } = require("electron");
const configProvider = require("./config");
let config;
function $(selector) { return document.querySelector(selector); }
const captionsSettingsButton = ElementFromFile(
templatePath(__dirname, "captions-settings-template.html")
);
module.exports = async () => {
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
}
function setup(api) {
$(".right-controls-buttons").append(captionsSettingsButton);
let captionTrackList = api.getOption("captions", "tracklist");
$("video").addEventListener("srcChanged", async () => {
if (config.disableCaptions) {
setTimeout(() => api.unloadModule("captions"), 100);
captionsSettingsButton.style.display = "none";
return;
}
api.loadModule("captions");
setTimeout(async () => {
captionTrackList = api.getOption("captions", "tracklist");
if (config.autoload && config.lastCaptionsCode) {
api.setOption("captions", "track", {
languageCode: config.lastCaptionsCode,
});
}
captionsSettingsButton.style.display = captionTrackList?.length
? "inline-block"
: "none";
}, 250);
});
captionsSettingsButton.onclick = async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption("captions", "track");
let currentIndex = !currentCaptionTrack ?
null :
captionTrackList.indexOf(captionTrackList.find(track => track.languageCode === currentCaptionTrack.languageCode));
const captionLabels = [
...captionTrackList.map(track => track.displayName),
'None'
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex)
if (currentIndex === null) return;
const newCaptions = captionTrackList[currentIndex];
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
if (newCaptions) {
api.setOption("captions", "track", { languageCode: newCaptions.languageCode });
} else {
api.setOption("captions", "track", {});
}
setTimeout(() => api.playVideo());
}
}
}

View File

@ -0,0 +1,20 @@
const config = require("./config");
module.exports = () => [
{
label: "Automatically select last used caption",
type: "checkbox",
checked: config.get("autoload"),
click: (item) => {
config.set('autoload', item.checked);
}
},
{
label: "No captions by default",
type: "checkbox",
checked: config.get("disabledCaptions"),
click: (item) => {
config.set('disableCaptions', item.checked);
},
}
];

View File

@ -0,0 +1,13 @@
<tp-yt-paper-icon-button class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles"
title="Open captions selector" aria-label="Open captions selector" role="button" tabindex="0" aria-disabled="false">
<tp-yt-iron-icon id="icon" class="style-scope tp-yt-paper-icon-button"><svg viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%;">
<g class="style-scope yt-icon">
<path
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"
class="style-scope tp-yt-iron-icon"></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>

15
plugins/crossfade/back.js Normal file
View File

@ -0,0 +1,15 @@
const { ipcMain } = require("electron");
const { Innertube } = require("youtubei.js");
require("./config");
module.exports = async () => {
const yt = await Innertube.create();
ipcMain.handle("audio-url", async (_, videoID) => {
const info = await yt.getBasicInfo(videoID);
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
return url;
});
};

View File

@ -0,0 +1,3 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("crossfade", { enableFront: true });
module.exports = { ...config };

360
plugins/crossfade/fader.js Normal file
View File

@ -0,0 +1,360 @@
/**
* VolumeFader
* Sophisticated Media Volume Fading
*
* Requires browser support for:
* - HTMLMediaElement
* - requestAnimationFrame()
* - ES6
*
* Does not depend on any third-party library.
*
* License: MIT
*
* Nick Schwarzenberg
* v0.2.0, 07/2016
*/
(function (root) {
"use strict";
// internal utility: check if value is a valid volume level and throw if not
let validateVolumeLevel = (value) => {
// number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// yup, that's fine
return;
} else {
// abort and throw an exception
throw new TypeError("Number between 0 and 1 expected as volume!");
}
};
// main class
class VolumeFader {
/**
* VolumeFader Constructor
*
* @param media {HTMLMediaElement} - audio or video element to be controlled
* @param options {Object} - an object with optional settings
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
*
* options:
* .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging)
* .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
* .initialVolume: {Number} media volume 0…1 to apply during setup (volume not touched by default)
* .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms)
*/
constructor(media, options) {
// passed media element of correct type?
if (media instanceof HTMLMediaElement) {
// save reference to media element
this.media = media;
} else {
// abort and throw an exception
throw new TypeError("Media element expected!");
}
// make sure options is an object
options = options || {};
// log function passed?
if (typeof options.logger == "function") {
// set log function to the one specified
this.logger = options.logger;
} else {
// set log function explicitly to false
this.logger = false;
}
// linear volume fading?
if (options.fadeScaling == "linear") {
// pass levels unchanged
this.scale = {
internalToVolume: (level) => level,
volumeToInternal: (level) => level,
};
// log setting
this.logger && this.logger("Using linear fading.");
}
// no linear, but logarithmic fading…
else {
let dynamicRange;
// default dynamic range?
if (
options.fadeScaling === undefined ||
options.fadeScaling == "logarithmic"
) {
// set default of 60 dB
dynamicRange = 3;
}
// custom dynamic range?
else if (
!Number.isNaN(options.fadeScaling) &&
options.fadeScaling > 0
) {
// turn amplitude dB into a multiple of 10 power dB
dynamicRange = options.fadeScaling / 2 / 10;
}
// unsupported value
else {
// abort and throw exception
throw new TypeError(
"Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!"
);
}
// use exponential/logarithmic scaler for expansion/compression
this.scale = {
internalToVolume: (level) =>
this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level) =>
this.logarithmicScaler(level, dynamicRange),
};
// log setting if not default
options.fadeScaling &&
this.logger &&
this.logger(
"Using logarithmic fading with " +
String(10 * dynamicRange) +
" dB dynamic range."
);
}
// set initial volume?
if (options.initialVolume !== undefined) {
// validate volume level and throw if invalid
validateVolumeLevel(options.initialVolume);
// set initial volume
this.media.volume = options.initialVolume;
// log setting
this.logger &&
this.logger(
"Set initial volume to " + String(this.media.volume) + "."
);
}
// fade duration given?
if (options.fadeDuration !== undefined) {
// try to set given fade duration (will log if successful and throw if not)
this.setFadeDuration(options.fadeDuration);
} else {
// set default fade duration (1000 ms)
this.fadeDuration = 1000;
}
// indicate that fader is not active yet
this.active = false;
// initialization done
this.logger && this.logger("Initialized for", this.media);
}
/**
* Re(start) the update cycle.
* (this.active must be truthy for volume updates to take effect)
*
* @return {Object} VolumeFader instance for chaining
*/
start() {
// set fader to be active
this.active = true;
// start by running the update method
this.updateVolume();
// return instance for chaining
return this;
}
/**
* Stop the update cycle.
* (interrupting any fade)
*
* @return {Object} VolumeFader instance for chaining
*/
stop() {
// set fader to be inactive
this.active = false;
// return instance for chaining
return this;
}
/**
* Set fade duration.
* (used for future calls to fadeTo)
*
* @param {Number} fadeDuration - fading length in milliseconds
* @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining
*/
setFadeDuration(fadeDuration) {
// if duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// set fade duration
this.fadeDuration = fadeDuration;
// log setting
this.logger &&
this.logger("Set fade duration to " + String(fadeDuration) + " ms.");
} else {
// abort and throw an exception
throw new TypeError("Positive number expected as fade duration!");
}
// return instance for chaining
return this;
}
/**
* Define a new fade and start fading.
*
* @param {Number} targetVolume - level to fade to in the range 0…1
* @param {Function} callback - (optional) function to be called when fade is complete
* @throws {TypeError} if targetVolume is not in the range 0…1
* @return {Object} VolumeFader instance for chaining
*/
fadeTo(targetVolume, callback) {
// validate volume and throw if invalid
validateVolumeLevel(targetVolume);
// define new fade
this.fade = {
// volume start and end point on internal fading scale
volume: {
start: this.scale.volumeToInternal(this.media.volume),
end: this.scale.volumeToInternal(targetVolume),
},
// time start and end point
time: {
start: Date.now(),
end: Date.now() + this.fadeDuration,
},
// optional callback function
callback: callback,
};
// start fading
this.start();
// log new fade
this.logger && this.logger("New fade started:", this.fade);
// return instance for chaining
return this;
}
// convenience shorthand methods for common fades
fadeIn(callback) {
this.fadeTo(1, callback);
}
fadeOut(callback) {
this.fadeTo(0, callback);
}
/**
* Internal: Update media volume.
* (calls itself through requestAnimationFrame)
*
* @param {Number} targetVolume - linear level to fade to (0…1)
* @param {Function} callback - (optional) function to be called when fade is complete
*/
updateVolume() {
// fader active and fade available to process?
if (this.active && this.fade) {
// get current time
let now = Date.now();
// time left for fading?
if (now < this.fade.time.end) {
// compute current fade progress
let progress =
(now - this.fade.time.start) /
(this.fade.time.end - this.fade.time.start);
// compute current level on internal scale
let level =
progress * (this.fade.volume.end - this.fade.volume.start) +
this.fade.volume.start;
// map fade level to volume level and apply it to media element
this.media.volume = this.scale.internalToVolume(level);
// schedule next update
root.requestAnimationFrame(this.updateVolume.bind(this));
} else {
// log end of fade
this.logger &&
this.logger(
"Fade to " + String(this.fade.volume.end) + " complete."
);
// time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
// set fader to be inactive
this.active = false;
// done, call back (if callable)
typeof this.fade.callback == "function" && this.fade.callback();
// clear fade
this.fade = undefined;
}
}
}
/**
* Internal: Exponential scaler with dynamic range limit.
*
* @param {Number} input - logarithmic input level to be expanded (float, 0…1)
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - expanded level (float, 0…1)
*/
exponentialScaler(input, dynamicRange) {
// special case: make zero (or any falsy input) return zero
if (input == 0) {
// since the dynamic range is limited,
// allow a zero to produce a plain zero instead of a small faction
// (audio would not be recognized as silent otherwise)
return 0;
} else {
// scale 0…1 to minus something × 10 dB
input = (input - 1) * dynamicRange;
// compute power of 10
return Math.pow(10, input);
}
}
/**
* Internal: Logarithmic scaler with dynamic range limit.
*
* @param {Number} input - exponential input level to be compressed (float, 0…1)
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - compressed level (float, 0…1)
*/
logarithmicScaler(input, dynamicRange) {
// special case: make zero (or any falsy input) return zero
if (input == 0) {
// logarithm of zero would be -∞, which would map to zero anyway
return 0;
} else {
// compute base-10 logarithm
input = Math.log10(input);
// scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + input / dynamicRange, 0);
}
}
}
// export class to root scope
root.VolumeFader = VolumeFader;
})(window);

158
plugins/crossfade/front.js Normal file
View File

@ -0,0 +1,158 @@
const { ipcRenderer } = require("electron");
const { Howl } = require("howler");
// Extracted from https://github.com/bitfasching/VolumeFader
require("./fader");
let transitionAudio; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition;
const defaultConfig = require("../../config/defaults").plugins.crossfade;
const configProvider = require("./config");
let config;
const configGetNum = (key) => Number(config[key]) || defaultConfig[key];
const getStreamURL = async (videoID) => {
const url = await ipcRenderer.invoke("audio-url", videoID);
return url;
};
const getVideoIDFromURL = (url) => {
return new URLSearchParams(url.split("?")?.at(-1)).get("v");
};
const isReadyToCrossfade = () => {
return transitionAudio && transitionAudio.state() === "loaded";
};
const watchVideoIDChanges = (cb) => {
navigation.addEventListener("navigate", (event) => {
const currentVideoID = getVideoIDFromURL(
event.currentTarget.currentEntry.url,
);
const nextVideoID = getVideoIDFromURL(event.destination.url);
if (
nextVideoID &&
currentVideoID &&
(firstVideo || nextVideoID !== currentVideoID)
) {
if (isReadyToCrossfade()) {
crossfade(() => {
cb(nextVideoID);
});
} else {
cb(nextVideoID);
firstVideo = false;
}
}
});
};
const createAudioForCrossfade = async (url) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
await syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = async () => {
const video = document.querySelector("video");
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNum("fadeScaling"),
fadeDuration: configGetNum("fadeInDuration"),
});
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
video.onseeking = () => {
transitionAudio.seek(video.currentTime);
};
video.onpause = () => {
transitionAudio.pause();
};
video.onplay = async () => {
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
// Fade in
const videoVolume = video.volume;
video.volume = 0;
videoFader.fadeTo(videoVolume);
};
// Exit just before the end for the transition
const transitionBeforeEnd = () => {
if (
video.currentTime >= video.duration - configGetNum("secondsBeforeEnd") &&
isReadyToCrossfade()
) {
video.removeEventListener("timeupdate", transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector(".next-button").click();
}
};
video.ontimeupdate = transitionBeforeEnd;
};
const onApiLoaded = () => {
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
await createAudioForCrossfade(url);
});
};
const crossfade = async (cb) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition;
waitForTransition = new Promise(function (resolve, reject) {
resolveTransition = resolve;
});
const video = document.querySelector("video");
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: configGetNum("fadeScaling"),
fadeDuration: configGetNum("fadeOutDuration"),
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
module.exports = async () => {
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener("apiLoaded", onApiLoaded, {
once: true,
passive: true,
});
};

72
plugins/crossfade/menu.js Normal file
View File

@ -0,0 +1,72 @@
const config = require("./config");
const defaultOptions = require("../../config/defaults").plugins.crossfade;
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
module.exports = (win) => [
{
label: "Advanced",
click: async () => {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) config.setAll(newOptions);
},
},
];
async function promptCrossfadeValues(win, options) {
const res = await prompt(
{
title: "Crossfade Options",
type: "multiInput",
multiInputOptions: [
{
label: "Fade in duration (ms)",
value: options.fadeInDuration || defaultOptions.fadeInDuration,
inputAttrs: {
type: "number",
required: true,
min: 0,
step: 100,
},
},
{
label: "Fade out duration (ms)",
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
inputAttrs: {
type: "number",
required: true,
min: 0,
step: 100,
},
},
{
label: "Crossfade x seconds before end",
value:
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
inputAttrs: {
type: "number",
required: true,
min: 0,
},
},
{
label: "Fade scaling",
selectOptions: { linear: "Linear", logarithmic: "Logarithmic" },
value: options.fadeScaling || defaultOptions.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
if (!res) return undefined;
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling: res[3],
};
}

View File

@ -1,66 +1,90 @@
const Discord = require("discord-rpc");
"use strict";
const Discord = require("@xhayper/discord-rpc");
const { dev } = require("electron-is");
const { dialog, app } = require("electron");
const registerCallback = require("../../providers/song-info");
// Application ID registered by @xn-oah
const clientId = "942539762227630162";
// Application ID registered by @Zo-Bro-23
const clientId = "1043858434585526382";
/**
* @typedef {Object} Info
* @property {import('discord-rpc').Client} rpc
* @property {import('@xhayper/discord-rpc').Client} rpc
* @property {boolean} ready
* @property {boolean} autoReconnect
* @property {import('../../providers/song-info').SongInfo} lastSongInfo
*/
/**
* @type {Info}
*/
const info = {
rpc: null,
rpc: new Discord.Client({
clientId
}),
ready: false,
autoReconnect: true,
lastSongInfo: null,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks = [];
const resetInfo = () => {
info.rpc = null;
info.ready = false;
clearTimeout(clearActivity);
if (dev()) console.log("discord disconnected");
refreshCallbacks.forEach(cb => cb());
};
info.rpc.on("connected", () => {
if (dev()) console.log("discord connected");
refreshCallbacks.forEach(cb => cb());
});
info.rpc.on("ready", () => {
info.ready = true;
if (info.lastSongInfo) updateActivity(info.lastSongInfo)
});
info.rpc.on("disconnected", () => {
resetInfo();
if (info.autoReconnect) {
connectTimeout();
}
});
const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
if (!info.autoReconnect || info.rpc.isConnected) return;
info.rpc.login().then(resolve).catch(reject);
}, 5000));
const connectRecursive = () => {
if (!info.autoReconnect || info.rpc.isConnected) return;
connectTimeout().catch(connectRecursive);
}
let window;
const connect = (showErr = false) => {
if (info.rpc) {
if (info.rpc.isConnected) {
if (dev())
console.log('Attempted to connect with active RPC object');
console.log('Attempted to connect with active connection');
return;
}
info.rpc = new Discord.Client({
transport: "ipc",
});
info.ready = false;
info.rpc.once("connected", () => {
if (dev()) console.log("discord connected");
refreshCallbacks.forEach(cb => cb());
});
info.rpc.once("ready", () => {
info.ready = true;
if (info.lastSongInfo) updateActivity(info.lastSongInfo)
});
info.rpc.once("disconnected", resetInfo);
// Startup the rpc client
info.rpc.login({ clientId }).catch(err => {
resetInfo();
if (dev()) console.error(err);
if (showErr) dialog.showMessageBox(window, { title: 'Connection failed', message: err.message || String(err), type: 'error' });
if (info.autoReconnect) {
connectRecursive();
}
else if (showErr) dialog.showMessageBox(window, { title: 'Connection failed', message: err.message || String(err), type: 'error' });
});
};
@ -70,7 +94,9 @@ let clearActivity;
*/
let updateActivity;
module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong }) => {
module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTime, listenAlong, hideDurationLeft }) => {
info.autoReconnect = autoReconnect;
window = win;
// We get multiple events
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
@ -92,7 +118,7 @@ module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong
// clear directly if timeout is 0
if (songInfo.isPaused && activityTimoutEnabled && activityTimoutTime === 0) {
info.rpc.clearActivity().catch(console.error);
info.rpc.user?.clearActivity().catch(console.error);
return;
}
@ -100,7 +126,6 @@ module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong
// @see https://discord.com/developers/docs/topics/gateway#activity-object
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const activityInfo = {
type: 2, // Listening, addressed in https://github.com/discordjs/RPC/pull/149
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc,
@ -116,8 +141,8 @@ module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong
activityInfo.smallImageText = "Paused";
// Set start the timer so the activity gets cleared after a while if enabled
if (activityTimoutEnabled)
clearActivity = setTimeout(() => info.rpc.clearActivity().catch(console.error), activityTimoutTime ?? 10000);
} else {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), activityTimoutTime ?? 10000);
} else if (!hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
activityInfo.startTimestamp = songStartTime;
@ -125,7 +150,7 @@ module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong
songStartTime + songInfo.songDuration * 1000;
}
info.rpc.setActivity(activityInfo).catch(console.error);
info.rpc.user?.setActivity(activityInfo).catch(console.error);
};
// If the page is ready, register the callback
@ -137,9 +162,10 @@ module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong
};
module.exports.clear = () => {
if (info.rpc) info.rpc.clearActivity();
if (info.rpc) info.rpc.user?.clearActivity();
clearTimeout(clearActivity);
};
module.exports.connect = connect;
module.exports.registerRefresh = (cb) => refreshCallbacks.push(cb);
module.exports.isConnected = () => info.rpc !== null;

View File

@ -4,13 +4,14 @@ const { setMenuOptions } = require("../../config/plugins");
const promptOptions = require("../../providers/prompt-options");
const { clear, connect, registerRefresh, isConnected } = require("./back");
let hasRegisterred = false;
const { singleton } = require("../../providers/decorators")
const registerRefreshOnce = singleton((refreshMenu) => {
registerRefresh(refreshMenu);
});
module.exports = (win, options, refreshMenu) => {
if (!hasRegisterred) {
registerRefresh(refreshMenu);
hasRegisterred = true;
}
registerRefreshOnce(refreshMenu);
return [
{
@ -18,6 +19,15 @@ module.exports = (win, options, refreshMenu) => {
enabled: !isConnected(),
click: connect,
},
{
label: "Auto reconnect",
type: "checkbox",
checked: options.autoReconnect,
click: (item) => {
options.autoReconnect = item.checked;
setMenuOptions('discord', options);
},
},
{
label: "Clear activity",
click: clear,
@ -40,6 +50,15 @@ module.exports = (win, options, refreshMenu) => {
setMenuOptions('discord', options);
},
},
{
label: "Hide duration left",
type: "checkbox",
checked: options.hideDurationLeft,
click: (item) => {
options.hideDurationLeft = item.checked;
setMenuOptions('discord', options);
}
},
{
label: "Set inactivity timeout",
click: () => setInactivityTimeout(win, options),

View File

@ -1,11 +0,0 @@
const CHANNEL = "downloader";
const ACTIONS = {
ERROR: "error",
METADATA: "metadata",
PROGRESS: "progress",
};
module.exports = {
CHANNEL: CHANNEL,
ACTIONS: ACTIONS,
};

View File

@ -1,98 +1,519 @@
const { writeFileSync } = require("fs");
const { join } = require("path");
const {
existsSync,
mkdirSync,
createWriteStream,
writeFileSync,
} = require('fs');
const { join } = require('path');
const ID3Writer = require("browser-id3-writer");
const { dialog, ipcMain } = require("electron");
const { fetchFromGenius } = require('../lyrics-genius/back');
const { isEnabled } = require('../../config/plugins');
const { getImage, cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const { cache } = require("../../providers/decorators")
const {
presets,
cropMaxWidth,
getFolder,
setBadge,
sendFeedback: sendFeedback_,
} = require('./utils');
const registerCallback = require("../../providers/song-info");
const { injectCSS, listenAction } = require("../utils");
const { cropMaxWidth } = require("./utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { isEnabled } = require("../../config/plugins");
const { getImage } = require("../../providers/song-info");
const { fetchFromGenius } = require("../lyrics-genius/back");
const { ipcMain, app, dialog } = require('electron');
const is = require('electron-is');
const { Innertube, UniversalCache, Utils, ClientType } = require('youtubei.js');
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
const sendError = (win, error) => {
win.setProgressBar(-1); // close progress bar
dialog.showMessageBox({
type: "info",
buttons: ["OK"],
title: "Error in download!",
message: "Argh! Apologies, download failed…",
detail: error.toString(),
});
const filenamify = require('filenamify');
const ID3Writer = require('browser-id3-writer');
const { randomBytes } = require('crypto');
const Mutex = require('async-mutex').Mutex;
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
log: false,
logger: () => {}, // console.log,
progress: () => {}, // console.log,
});
const ffmpegMutex = new Mutex();
const config = require('./config');
/** @type {Innertube} */
let yt;
let win;
let playingUrl = undefined;
const sendError = (error, source) => {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge
sendFeedback_(win); // reset feedback
const songNameMessage = source ? `\nin ${source}` : '';
const cause = error.cause ? `\n\n${error.cause.toString()}` : '';
const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message);
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Error in download!',
message: 'Argh! Apologies, download failed…',
detail: message,
});
};
let nowPlayingMetadata = {};
module.exports = async (win_) => {
win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css'));
function handle(win) {
injectCSS(win.webContents, join(__dirname, "style.css"));
registerCallback((info) => {
nowPlayingMetadata = info;
});
yt = await Innertube.create({
cache: new UniversalCache(false),
generate_session_locally: true,
});
ipcMain.on('download-song', (_, url) => downloadSong(url));
ipcMain.on('video-src-changed', async (_, data) => {
playingUrl =
JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
});
ipcMain.on('download-playlist-request', async (_event, url) =>
downloadPlaylist(url),
);
};
listenAction(CHANNEL, (event, action, arg) => {
switch (action) {
case ACTIONS.ERROR: // arg = error
sendError(win, arg);
break;
case ACTIONS.METADATA:
event.returnValue = JSON.stringify(nowPlayingMetadata);
break;
case ACTIONS.PROGRESS: // arg = progress
win.setProgressBar(arg);
break;
default:
console.log("Unknown action: " + action);
}
});
module.exports.downloadSong = downloadSong;
module.exports.downloadPlaylist = downloadPlaylist;
ipcMain.on("add-metadata", async (event, filePath, songBuffer, currentMetadata) => {
let fileBuffer = songBuffer;
const songMetadata = currentMetadata.imageSrcYTPL ? // This means metadata come from ytpl.getInfo();
{
...currentMetadata,
image: cropMaxWidth(await getImage(currentMetadata.imageSrcYTPL))
} :
{ ...nowPlayingMetadata, ...currentMetadata };
try {
const coverBuffer = songMetadata.image && !songMetadata.image.isEmpty() ?
songMetadata.image.toPNG() : null;
const writer = new ID3Writer(songBuffer);
// Create the metadata tags
writer
.setFrame("TIT2", songMetadata.title)
.setFrame("TPE1", [songMetadata.artist]);
if (coverBuffer) {
writer.setFrame("APIC", {
type: 3,
data: coverBuffer,
description: ""
});
}
if (isEnabled("lyrics-genius")) {
const lyrics = await fetchFromGenius(songMetadata);
if (lyrics) {
writer.setFrame("USLT", {
description: lyrics,
lyrics: lyrics,
});
}
}
writer.addTag();
fileBuffer = Buffer.from(writer.arrayBuffer);
} catch (error) {
sendError(win, error);
}
writeFileSync(filePath, fileBuffer);
// Notify the youtube-dl file
event.reply("add-metadata-done");
});
async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
) {
let resolvedName = undefined;
try {
await downloadSongUnsafe(
url,
name=>resolvedName=name,
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error) {
sendError(error, resolvedName || url);
}
}
module.exports = handle;
module.exports.sendError = sendError;
async function downloadSongUnsafe(
url,
setName,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
) {
const sendFeedback = (message, progress) => {
if (!playlistFolder) {
sendFeedback_(win, message);
if (!isNaN(progress)) {
win.setProgressBar(progress);
}
}
};
sendFeedback('Downloading...', 2);
const id = getVideoId(url);
let info = await yt.music.getInfo(id);
if (!info) {
throw new Error('Video not found');
}
const metadata = getMetadata(info);
if (metadata.album === 'N/A') metadata.album = '';
metadata.trackId = trackId;
const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title
}`;
setName(name);
let playabilityStatus = info.playability_status;
let bypassedResult = null;
if (playabilityStatus.status === "LOGIN_REQUIRED") {
// try to bypass the age restriction
bypassedResult = await getAndroidTvInfo(id);
playabilityStatus = bypassedResult.playability_status;
if (playabilityStatus.status === "LOGIN_REQUIRED") {
throw new Error(
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
);
}
info = bypassedResult;
}
if (playabilityStatus.status === "UNPLAYABLE") {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
);
}
const extension = presets[config.get('preset')]?.extension || 'mp3';
const filename = filenamify(`${name}.${extension}`, {
replacement: '_',
maxLength: 255,
});
const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) {
sendFeedback(null, -1);
return;
}
const download_options = {
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any', // media container format
};
const format = info.chooseFormat(download_options);
const stream = await info.download(download_options);
console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`,
);
const iterableStream = Utils.streamToIterable(stream);
if (!existsSync(dir)) {
mkdirSync(dir);
}
if (!presets[config.get('preset')]) {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
} else {
const file = createWriteStream(filePath);
let downloaded = 0;
const total = format.content_length;
for await (const chunk of iterableStream) {
downloaded += chunk.length;
const ratio = downloaded / total;
const progress = Math.floor(ratio * 100);
sendFeedback(`Download: ${progress}%`, ratio);
increasePlaylistProgress(ratio);
file.write(chunk);
}
await ffmpegWriteTags(
filePath,
metadata,
presets[config.get('preset')]?.ffmpegArgs,
);
sendFeedback(null, -1);
}
sendFeedback(null, -1);
console.info(`Done: "${filePath}"`);
}
async function iterableStreamToMP3(
stream,
metadata,
content_length,
sendFeedback,
increasePlaylistProgress = () => {},
) {
const chunks = [];
let downloaded = 0;
const total = content_length;
for await (const chunk of stream) {
downloaded += chunk.length;
chunks.push(chunk);
const ratio = downloaded / total;
const progress = Math.floor(ratio * 100);
sendFeedback(`Download: ${progress}%`, ratio);
// 15% for download, 85% for conversion
// This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15);
}
sendFeedback('Loading…', 2); // indefinite progress bar after download
const buffer = Buffer.concat(chunks);
const safeVideoName = randomBytes(32).toString('hex');
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
sendFeedback('Preparing file…');
ffmpeg.FS('writeFile', safeVideoName, buffer);
sendFeedback('Converting…');
ffmpeg.setProgress(({ ratio }) => {
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
increasePlaylistProgress(0.15 + ratio * 0.85);
});
await ffmpeg.run(
'-i',
safeVideoName,
...getFFmpegMetadataArgs(metadata),
`${safeVideoName}.mp3`,
);
sendFeedback('Saving…');
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
} catch (e) {
sendError(e, safeVideoName);
} finally {
releaseFFmpegMutex();
}
}
const getCoverBuffer = cache(async (url) => {
const nativeImage = cropMaxWidth(await getImage(url));
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
});
async function writeID3(buffer, metadata, sendFeedback) {
try {
sendFeedback('Writing ID3 tags...');
const coverBuffer = await getCoverBuffer(metadata.image);
const writer = new ID3Writer(buffer);
// Create the metadata tags
writer.setFrame('TIT2', metadata.title).setFrame('TPE1', [metadata.artist]);
if (metadata.album) {
writer.setFrame('TALB', metadata.album);
}
if (coverBuffer) {
writer.setFrame('APIC', {
type: 3,
data: coverBuffer,
description: '',
});
}
if (isEnabled('lyrics-genius')) {
const lyrics = await fetchFromGenius(metadata);
if (lyrics) {
writer.setFrame('USLT', {
description: '',
lyrics: lyrics,
});
}
}
if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId);
}
writer.addTag();
return Buffer.from(writer.arrayBuffer);
} catch (e) {
sendError(e, `${metadata.artist} - ${metadata.title}`);
}
}
async function downloadPlaylist(givenUrl) {
try {
givenUrl = new URL(givenUrl);
} catch {
givenUrl = undefined;
}
const playlistId =
getPlaylistID(givenUrl) ||
getPlaylistID(new URL(win.webContents.getURL())) ||
getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(new Error('No playlist ID found'));
return;
}
const sendFeedback = (message) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback('Getting playlist info…');
let playlist;
try {
playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Infinity,
});
} catch (e) {
sendError(
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${e}`,
);
return;
}
if (playlist.items.length === 0) sendError(new Error('Playlist is empty'));
if (playlist.items.length === 1) {
sendFeedback('Playlist has only one item, downloading it directly');
await downloadSong(playlist.items[0].url);
return;
}
const isAlbum = playlist.title.startsWith('Album - ');
if (isAlbum) {
playlist.title = playlist.title.slice(8);
}
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(config.get('downloadFolder'));
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) {
sendError(new Error(`The folder ${playlistFolder} already exists`));
return;
}
} else {
mkdirSync(playlistFolder, { recursive: true });
}
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Started Download',
message: `Downloading Playlist "${playlist.title}"`,
detail: `(${playlist.items.length} songs)`,
});
if (is.dev()) {
console.log(
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`,
);
}
win.setProgressBar(2); // starts with indefinite bar
setBadge(playlist.items.length);
let counter = 1;
const progressStep = 1 / playlist.items.length;
const increaseProgress = (itemPercentage) => {
const currentProgress = (counter - 1) / playlist.items.length;
const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress);
};
try {
for (const song of playlist.items) {
sendFeedback(`Downloading ${counter}/${playlist.items.length}...`);
const trackId = isAlbum ? counter : undefined;
await downloadSong(
song.url,
playlistFolder,
trackId,
increaseProgress,
).catch((e) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${e}`,
),
);
win.setProgressBar(counter / playlist.items.length);
setBadge(playlist.items.length - counter);
counter++;
}
} catch (e) {
sendError(e);
} finally {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
sendFeedback(); // clear feedback
}
}
async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
await ffmpeg.run(
'-i',
filePath,
...getFFmpegMetadataArgs(metadata),
...ffmpegArgs,
filePath,
);
} catch (e) {
sendError(e);
} finally {
releaseFFmpegMutex();
}
}
function getFFmpegMetadataArgs(metadata) {
if (!metadata) {
return;
}
return [
...(metadata.title ? ['-metadata', `title=${metadata.title}`] : []),
...(metadata.artist ? ['-metadata', `artist=${metadata.artist}`] : []),
...(metadata.album ? ['-metadata', `album=${metadata.album}`] : []),
...(metadata.trackId ? ['-metadata', `track=${metadata.trackId}`] : []),
];
}
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL) => {
const result =
aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
}
return result;
};
const getVideoId = (url) => {
if (typeof url === 'string') {
url = new URL(url);
}
return url.searchParams.get('v');
};
const getMetadata = (info) => ({
id: info.basic_info.id,
title: cleanupName(info.basic_info.title),
artist: cleanupName(info.basic_info.author),
album: info.player_overlays?.browser_media_session?.album?.text,
image: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
});
// This is used to bypass age restrictions
const getAndroidTvInfo = async (id) => {
const innertube = await Innertube.create({
clientType: ClientType.TV_EMBEDDED,
generate_session_locally: true,
retrieve_player: true,
});
const info = await innertube.getBasicInfo(id, 'TV_EMBEDDED');
// getInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data
return info;
}

View File

@ -0,0 +1,3 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('downloader');
module.exports = { ...config };

View File

@ -2,97 +2,68 @@ const { ipcRenderer } = require("electron");
const { defaultConfig } = require("../../config");
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath, triggerAction } = require("../utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { downloadVideoToMP3 } = require("./youtube-dl");
const { ElementFromFile, templatePath } = require("../utils");
let menu = null;
let progress = null;
const downloadButton = ElementFromFile(
templatePath(__dirname, "download.html")
);
let pluginOptions = {};
const observer = new MutationObserver(() => {
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) return;
}
if (menu.contains(downloadButton)) return;
const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (menuUrl && !menuUrl.includes('watch?')) return;
if (!menuUrl?.includes('watch?') && doneFirstLoad) return;
menu.prepend(downloadButton);
progress = document.querySelector("#ytmcustom-download");
if (doneFirstLoad) return;
setTimeout(() => doneFirstLoad ||= true, 500);
});
const reinit = () => {
triggerAction(CHANNEL, ACTIONS.PROGRESS, -1); // closes progress bar
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = "Download";
}
};
const baseUrl = defaultConfig.url;
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
global.download = () => {
triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar
let metadata;
let videoUrl = getSongMenu()
// selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
?.getAttribute("href");
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = baseUrl + "/" + videoUrl;
videoUrl = defaultConfig.url + "/" + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
metadata = null;
} else {
metadata = global.songInfo;
videoUrl = metadata.url || window.location.href;
videoUrl = global.songInfo.url || window.location.href;
}
downloadVideoToMP3(
videoUrl,
(feedback, ratio = undefined) => {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback;
}
if (ratio) {
triggerAction(CHANNEL, ACTIONS.PROGRESS, ratio);
}
},
(error) => {
triggerAction(CHANNEL, ACTIONS.ERROR, error);
reinit();
},
reinit,
pluginOptions,
metadata
);
ipcRenderer.send('download-song', videoUrl);
};
// });
function observeMenu(options) {
pluginOptions = { ...pluginOptions, ...options };
module.exports = () => {
document.addEventListener('apiLoaded', () => {
observer.observe(document.querySelector('ytmusic-popup-container'), {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
}, { once: true, passive: true })
}
module.exports = observeMenu;
ipcRenderer.on('downloader-feedback', (_, feedback) => {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback || "Download";
}
});
};

View File

@ -1,55 +1,24 @@
const { existsSync, mkdirSync } = require("fs");
const { join } = require("path");
const { dialog } = require("electron");
const { dialog, ipcMain } = require("electron");
const is = require("electron-is");
const ytpl = require("ytpl");
const chokidar = require('chokidar');
const filenamify = require('filenamify');
const { setMenuOptions } = require("../../config/plugins");
const { sendError } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets, setBadge } = require("./utils");
let downloadLabel = defaultMenuDownloadLabel;
let playingUrl = undefined;
let callbackIsRegistered = false;
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = aURL => {
const result = aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist");
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(6)
}
return result;
};
module.exports = (win, options) => {
if (!callbackIsRegistered) {
ipcMain.on("video-src-changed", async (_, data) => {
playingUrl = JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
});
ipcMain.on("download-playlist-request", async (_event, url) => downloadPlaylist(url, win, options));
callbackIsRegistered = true;
}
const { downloadPlaylist } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils");
const config = require("./config");
module.exports = () => {
return [
{
label: downloadLabel,
click: () => downloadPlaylist(undefined, win, options),
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),
},
{
label: "Choose download folder",
click: () => {
let result = dialog.showOpenDialogSync({
const result = dialog.showOpenDialogSync({
properties: ["openDirectory", "createDirectory"],
defaultPath: getFolder(options.downloadFolder),
defaultPath: getFolder(config.get("downloadFolder")),
});
if (result) {
options.downloadFolder = result[0];
setMenuOptions("downloader", options);
config.set("downloadFolder", result[0]);
} // else = user pressed cancel
},
},
@ -58,94 +27,19 @@ module.exports = (win, options) => {
submenu: Object.keys(presets).map((preset) => ({
label: preset,
type: "radio",
checked: config.get("preset") === preset,
click: () => {
options.preset = preset;
setMenuOptions("downloader", options);
config.set("preset", preset);
},
checked: options.preset === preset || presets[preset] === undefined,
})),
},
{
label: "Skip existing files",
type: "checkbox",
checked: config.get("skipExisting"),
click: (item) => {
config.set("skipExisting", item.checked);
},
},
];
};
async function downloadPlaylist(givenUrl, win, options) {
if (givenUrl) {
try {
givenUrl = new URL(givenUrl);
} catch {
givenUrl = undefined;
};
}
const playlistId = getPlaylistID(givenUrl)
|| getPlaylistID(new URL(win.webContents.getURL()))
|| getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(win, new Error("No playlist ID found"));
return;
}
console.log(`trying to get playlist ID: '${playlistId}'`);
let playlist;
try {
playlist = await ytpl(playlistId, {
limit: options.playlistMaxItems || Infinity,
});
} catch (e) {
sendError(win, e);
return;
}
const safePlaylistTitle = filenamify(playlist.title, {replacement: ' '});
const folder = getFolder(options.downloadFolder);
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
sendError(
win,
new Error(`The folder ${playlistFolder} already exists`)
);
return;
}
mkdirSync(playlistFolder, { recursive: true });
dialog.showMessageBox({
type: "info",
buttons: ["OK"],
title: "Started Download",
message: `Downloading Playlist "${playlist.title}"`,
detail: `(${playlist.items.length} songs)`,
});
if (is.dev()) {
console.log(
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`
);
}
win.setProgressBar(2); // starts with indefinite bar
let downloadCount = 0;
setBadge(playlist.items.length);
let dirWatcher = chokidar.watch(playlistFolder);
dirWatcher.on('add', () => {
downloadCount += 1;
if (downloadCount >= playlist.items.length) {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
dirWatcher.close().then(() => (dirWatcher = null));
} else {
win.setProgressBar(downloadCount / playlist.items.length);
setBadge(playlist.items.length - downloadCount);
}
});
playlist.items.forEach((song) => {
win.webContents.send(
"downloader-download-playlist",
song.url,
safePlaylistTitle,
options
);
});
}

View File

@ -1,20 +1,12 @@
const electron = require("electron");
const { app } = require("electron");
const is = require('electron-is');
module.exports.getFolder = customFolder => customFolder || electron.app.getPath("downloads");
module.exports.getFolder = customFolder => customFolder || app.getPath("downloads");
module.exports.defaultMenuDownloadLabel = "Download playlist";
const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"];
module.exports.urlToJPG = (imgUrl, videoId) => {
if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl;
//it will almost never get further than hqdefault
for (const quality of orderedQualityList) {
if (imgUrl.includes(quality)) {
return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`;
}
}
return `https://img.youtube.com/vi/${videoId}/default.jpg`;
}
module.exports.sendFeedback = (win, message) => {
win.webContents.send("downloader-feedback", message);
};
module.exports.cropMaxWidth = (image) => {
const imageSize = image.getSize();
@ -41,6 +33,6 @@ module.exports.presets = {
module.exports.setBadge = n => {
if (is.linux() || is.macOS()) {
electron.app.setBadgeCount(n);
app.setBadgeCount(n);
}
}

View File

@ -1,200 +0,0 @@
const { randomBytes } = require("crypto");
const { join } = require("path");
const Mutex = require("async-mutex").Mutex;
const { ipcRenderer } = require("electron");
const is = require("electron-is");
const filenamify = require("filenamify");
// Browser version of FFmpeg (in renderer process) instead of loading @ffmpeg/ffmpeg
// because --js-flags cannot be passed in the main process when the app is packaged
// See https://github.com/electron/electron/issues/22705
const FFmpeg = require("@ffmpeg/ffmpeg/dist/ffmpeg.min");
const ytdl = require("ytdl-core");
const { triggerAction, triggerActionSync } = require("../utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { presets, urlToJPG } = require("./utils");
const { cleanupName } = require("../../providers/song-info");
const { createFFmpeg } = FFmpeg;
const ffmpeg = createFFmpeg({
log: false,
logger: () => {}, // console.log,
progress: () => {}, // console.log,
});
const ffmpegMutex = new Mutex();
const downloadVideoToMP3 = async (
videoUrl,
sendFeedback,
sendError,
reinit,
options,
metadata = undefined,
subfolder = ""
) => {
sendFeedback("Downloading…");
if (metadata === null) {
const { videoDetails } = await ytdl.getInfo(videoUrl);
const thumbnails = videoDetails?.thumbnails;
metadata = {
artist:
videoDetails?.media?.artist ||
cleanupName(videoDetails?.author?.name) ||
"",
title: videoDetails?.media?.song || videoDetails?.title || "",
imageSrcYTPL: thumbnails ?
urlToJPG(thumbnails[thumbnails.length - 1].url, videoDetails?.videoId)
: ""
}
}
let videoName = "YouTube Music - Unknown title";
let videoReadableStream;
try {
videoReadableStream = ytdl(videoUrl, {
filter: "audioonly",
quality: "highestaudio",
highWaterMark: 32 * 1024 * 1024, // 32 MB
requestOptions: { maxRetries: 3 },
});
} catch (err) {
sendError(err);
return;
}
const chunks = [];
videoReadableStream
.on("data", (chunk) => {
chunks.push(chunk);
})
.on("progress", (_chunkLength, downloaded, total) => {
const ratio = downloaded / total;
const progress = Math.floor(ratio * 100);
sendFeedback("Download: " + progress + "%", ratio);
})
.on("info", (info, format) => {
videoName = info.videoDetails.title.replace("|", "").toString("ascii");
if (is.dev()) {
console.log(
"Downloading video - name:",
videoName,
"- quality:",
format.audioBitrate + "kbits/s"
);
}
})
.on("error", sendError)
.on("end", async () => {
const buffer = Buffer.concat(chunks);
await toMP3(
videoName,
buffer,
sendFeedback,
sendError,
reinit,
options,
metadata,
subfolder
);
});
};
const toMP3 = async (
videoName,
buffer,
sendFeedback,
sendError,
reinit,
options,
existingMetadata = undefined,
subfolder = ""
) => {
const convertOptions = { ...presets[options.preset], ...options };
const safeVideoName = randomBytes(32).toString("hex");
const extension = convertOptions.extension || "mp3";
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
sendFeedback("Loading…", 2); // indefinite progress bar after download
await ffmpeg.load();
}
sendFeedback("Preparing file…");
ffmpeg.FS("writeFile", safeVideoName, buffer);
sendFeedback("Converting…");
const metadata = existingMetadata || getMetadata();
await ffmpeg.run(
"-i",
safeVideoName,
...getFFmpegMetadataArgs(metadata),
...(convertOptions.ffmpegArgs || []),
safeVideoName + "." + extension
);
const folder = options.downloadFolder || await ipcRenderer.invoke('getDownloadsFolder');
const name = metadata.title
? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}`
: videoName;
const filename = filenamify(name + "." + extension, {
replacement: "_",
maxLength: 255,
});
const filePath = join(folder, subfolder, filename);
const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension);
// Add the metadata
sendFeedback("Adding metadata…");
ipcRenderer.send("add-metadata", filePath, fileBuffer, {
artist: metadata.artist,
title: metadata.title,
imageSrcYTPL: metadata.imageSrcYTPL
});
ipcRenderer.once("add-metadata-done", reinit);
} catch (e) {
sendError(e);
} finally {
releaseFFmpegMutex();
}
};
const getMetadata = () => {
return JSON.parse(triggerActionSync(CHANNEL, ACTIONS.METADATA));
};
const getFFmpegMetadataArgs = (metadata) => {
if (!metadata) {
return;
}
return [
...(metadata.title ? ["-metadata", `title=${metadata.title}`] : []),
...(metadata.artist ? ["-metadata", `artist=${metadata.artist}`] : []),
];
};
module.exports = {
downloadVideoToMP3,
};
ipcRenderer.on(
"downloader-download-playlist",
(_, url, playlistFolder, options) => {
downloadVideoToMP3(
url,
() => {},
(error) => {
triggerAction(CHANNEL, ACTIONS.ERROR, error);
},
() => {},
options,
null,
playlistFolder
);
}
);

View File

@ -2,14 +2,12 @@ const path = require("path");
const electronLocalshortcut = require("electron-localshortcut");
const config = require("../../config");
const { injectCSS } = require("../utils");
const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main');
setupTitlebar();
//tracks menu visibility
let visible = !config.get("options.hideMenu");
module.exports = (win) => {
// css for custom scrollbar + disable drag area(was causing bugs)
@ -18,16 +16,8 @@ module.exports = (win) => {
win.once("ready-to-show", () => {
attachTitlebarToWindow(win);
//register keyboard shortcut && hide menu if hideMenu is enabled
if (config.get("options.hideMenu")) {
electronLocalshortcut.register(win, "Esc", () => {
setMenuVisibility(!visible);
});
}
electronLocalshortcut.register(win, "`", () => {
win.webContents.send("toggleMenu");
});
});
function setMenuVisibility(value) {
visible = value;
win.webContents.send("refreshMenu", visible);
}
};

View File

@ -5,28 +5,31 @@ const { isEnabled } = require("../../config/plugins");
function $(selector) { return document.querySelector(selector); }
module.exports = (options) => {
let visible = !config.get("options.hideMenu");
let visible = () => !!$('.cet-menubar').firstChild;
const bar = new Titlebar({
icon: "https://cdn-icons-png.flaticon.com/512/5358/5358672.png",
backgroundColor: Color.fromHex("#050505"),
itemBackgroundColor: Color.fromHex("#1d1d1d"),
svgColor: Color.WHITE,
menu: visible ? undefined : null
menu: config.get("options.hideMenu") ? null : undefined
});
bar.updateTitle(" ");
document.title = "Youtube Music";
const hideIcon = hide => $('.cet-window-icon').style.display = hide ? 'none' : 'flex';
if (options.hideIcon) hideIcon(true);
ipcRenderer.on("refreshMenu", (_, showMenu) => {
if (showMenu === undefined && !visible) return;
if (showMenu === false) {
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null);
visible = false;
} else {
bar.refreshMenu();
visible = true;
}
};
$('.cet-window-icon').addEventListener('click', toggleMenu);
ipcRenderer.on("toggleMenu", toggleMenu);
ipcRenderer.on("refreshMenu", () => {
if (visible()) {
bar.refreshMenu();
}
});
@ -36,16 +39,33 @@ module.exports = (options) => {
});
}
ipcRenderer.on("hideIcon", (_, hide) => hideIcon(hide));
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
document.addEventListener('apiLoaded', () => {
setNavbarMargin();
const playPageObserver = new MutationObserver(setNavbarMargin);
playPageObserver.observe($('ytmusic-app-layout'), { attributeFilter: ['player-page-open_', 'playerPageOpen_'] })
setupSearchOpenObserver();
setupMenuOpenObserver();
}, { once: true, passive: true })
};
function setupSearchOpenObserver() {
const searchOpenObserver = new MutationObserver(mutations => {
$('#nav-bar-background').style.webkitAppRegion =
mutations[0].target.opened ? 'no-drag' : 'drag';
});
searchOpenObserver.observe($('ytmusic-search-box'), { attributeFilter: ["opened"] })
}
function setupMenuOpenObserver() {
const menuOpenObserver = new MutationObserver(mutations => {
$('#nav-bar-background').style.webkitAppRegion =
Array.from($('.cet-menubar').childNodes).some(c => c.classList.contains('open')) ?
'no-drag' : 'drag';
});
menuOpenObserver.observe($('.cet-menubar'), { subtree: true, attributeFilter: ["class"] })
}
function setNavbarMargin() {
$('#nav-bar-background').style.right =
$('ytmusic-app-layout').playerPageOpen_ ?

View File

@ -1,14 +0,0 @@
const { setOptions } = require("../../config/plugins");
module.exports = (win, options) => [
{
label: "Hide Icon",
type: "checkbox",
checked: options.hideIcon,
click: (item) => {
win.webContents.send("hideIcon", item.checked);
options.hideIcon = item.checked;
setOptions("in-app-menu", options);
},
}
];

View File

@ -8,16 +8,22 @@
#nav-bar-background {
opacity: 1 !important;
pointer-events: none !important;
position: sticky !important;
top: 0 !important;
top: 30px !important;
height: 75px !important;
}
/* fixes top gap between nav-bar and browse-page */
/* fix top gap between nav-bar and browse-page */
#browse-page {
padding-top: 0 !important;
}
/* fix navbar hiding library items */
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_LIBRARY_CONTENT_LANDING_PAGE"],
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_PRIVATELY_OWNED_CONTENT_LANDING_PAGE"] {
top: 50px;
position: relative;
}
/* remove window dragging for nav bar (conflict with titlebar drag) */
ytmusic-nav-bar,
.tab-titleiron-icon,
@ -47,7 +53,7 @@ yt-page-navigation-progress,
top: 30px !important;
}
/* Custom scrollbar */
/* custom scrollbar */
::-webkit-scrollbar {
width: 12px;
background-color: #030303;
@ -60,7 +66,7 @@ yt-page-navigation-progress,
background-color: rgba(15, 15, 15, 0.699);
}
/* The scrollbar 'thumb' ...that marque oval shape in a scrollbar */
/* the scrollbar 'thumb' ...that marque oval shape in a scrollbar */
::-webkit-scrollbar-thumb:vertical {
border: 2px solid rgba(0, 0, 0, 0);
@ -71,7 +77,7 @@ yt-page-navigation-progress,
-webkit-border-radius: 100px;
}
::-webkit-scrollbar-thumb:vertical:active {
background: #4d4c4c; /* Some darker color when you click it */
background: #4d4c4c; /* some darker color when you click it */
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
@ -80,3 +86,26 @@ yt-page-navigation-progress,
.cet-menubar-menu-container .cet-action-item {
background-color: inherit
}
/** hideMenu toggler **/
.cet-window-icon {
-webkit-app-region: no-drag;
}
.cet-window-icon img {
-webkit-user-drag: none;
filter: invert(50%);
}
/** make navbar draggable **/
#nav-bar-background {
-webkit-app-region: drag;
}
ytmusic-nav-bar input,
ytmusic-nav-bar span,
ytmusic-nav-bar [role="button"],
ytmusic-nav-bar yt-icon,
tp-yt-iron-dropdown {
-webkit-app-region: no-drag;
}

View File

@ -7,8 +7,13 @@ const fetch = require("node-fetch");
const { cleanupName } = require("../../providers/song-info");
const { injectCSS } = require("../utils");
let eastAsianChars = /\p{Script=Han}|\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
module.exports = async (win) => {
module.exports = async (win, options) => {
if(options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, join(__dirname, "style.css"));
ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => {
@ -17,17 +22,51 @@ module.exports = async (win) => {
});
};
const toggleRomanized = () => {
revRomanized = !revRomanized;
};
const fetchFromGenius = async (metadata) => {
const queryString = `${cleanupName(metadata.artist)} ${cleanupName(
metadata.title
)}`;
const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics;
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
Genius Lyrics behavior is observed.
*/
let hasAsianChars = false;
if (revRomanized && (eastAsianChars.test(songTitle) || eastAsianChars.test(songArtist))) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
hasAsianChars = true;
} else {
lyrics = await getLyricsList(`${songArtist} ${songTitle}`);
}
/* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check
for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics.
*/
if(revRomanized && !hasAsianChars && eastAsianChars.test(lyrics)) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
}
return lyrics;
};
/**
* Fetches a JSON of songs which is then parsed and passed into getLyrics to get the lyrical content of the first song
* @param {*} queryString
* @returns The lyrics of the first song found using the Genius-Lyrics API
*/
const getLyricsList = async (queryString) => {
let response = await fetch(
`https://genius.com/api/search/multi?per_page=5&q=${encodeURI(queryString)}`
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`
);
if (!response.ok) {
return null;
}
/* Fetch the first URL with the api, giving a collection of song results.
Pick the first song, parsing the json given by the API.
*/
const info = await response.json();
let url = "";
try {
@ -36,16 +75,23 @@ const fetchFromGenius = async (metadata) => {
} catch {
return null;
}
let lyrics = await getLyrics(url);
return lyrics;
}
if (is.dev()) {
console.log("Fetching lyrics from Genius:", url);
}
/**
*
* @param {*} url
* @returns The lyrics of the song URL provided, null if none
*/
const getLyrics = async (url) => {
response = await fetch(url);
if (!response.ok) {
return null;
}
if (is.dev()) {
console.log("Fetching lyrics from Genius:", url);
}
const html = await response.text();
const lyrics = convert(html, {
baseElements: {
@ -64,8 +110,8 @@ const fetchFromGenius = async (metadata) => {
},
},
});
return lyrics;
};
module.exports.fetchFromGenius = fetchFromGenius;
module.exports.toggleRomanized = toggleRomanized;
module.exports.fetchFromGenius = fetchFromGenius;

View File

@ -2,7 +2,7 @@ const { ipcRenderer } = require("electron");
const is = require("electron-is");
module.exports = () => {
ipcRenderer.on("update-song-info", (_, extractedSongInfo) => {
ipcRenderer.on("update-song-info", (_, extractedSongInfo) => setTimeout(() => {
const tabList = document.querySelectorAll("tp-yt-paper-tab");
const tabs = {
upNext: tabList[0],
@ -90,5 +90,5 @@ module.exports = () => {
tabs.lyrics.removeAttribute("disabled");
tabs.lyrics.removeAttribute("aria-disabled");
}
});
}, 500));
};

View File

@ -0,0 +1,17 @@
const { setOptions } = require("../../config/plugins");
const { toggleRomanized } = require("./back");
module.exports = (win, options, refreshMenu) => {
return [
{
label: "Romanized Lyrics",
type: "checkbox",
checked: options.romanizedLyrics,
click: (item) => {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];
};

View File

@ -2,8 +2,9 @@ const { Notification } = require("electron");
const is = require("electron-is");
const registerCallback = require("../../providers/song-info");
const { notificationImage } = require("./utils");
const config = require("./config");
const notify = (info, options) => {
const notify = (info) => {
// Fill the notification with content
const notification = {
@ -11,7 +12,7 @@ const notify = (info, options) => {
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: options.urgency,
urgency: config.get('urgency'),
};
// Send the notification
@ -21,24 +22,25 @@ const notify = (info, options) => {
return currentNotification;
};
const setup = (options) => {
const setup = () => {
let oldNotification;
let currentUrl;
registerCallback(songInfo => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || options.unpauseNotification)) {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
// Close the old notification
oldNotification?.close();
currentUrl = songInfo.url;
// This fixes a weird bug that would cause the notification to be updated instead of showing
setTimeout(() => { oldNotification = notify(songInfo, options) }, 10);
setTimeout(() => { oldNotification = notify(songInfo) }, 10);
}
});
}
/** @param {Electron.BrowserWindow} win */
module.exports = (win, options) => {
// Register the callback for new song information
is.windows() && options.interactive ?
require("./interactive")(win, options.unpauseNotification) :
setup(options);
require("./interactive")(win) :
setup();
};

View File

@ -0,0 +1,5 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("notifications");
module.exports = { ...config };

View File

@ -1,106 +1,235 @@
const { notificationImage, icons } = require("./utils");
const { notificationImage, icons, save_temp_icons, secondsToMinutes, ToastStyles } = require("./utils");
const getSongControls = require('../../providers/song-controls');
const registerCallback = require("../../providers/song-info");
const is = require("electron-is");
const WindowsToaster = require('node-notifier').WindowsToaster;
const { changeProtocolHandler } = require("../../providers/protocol-handler");
const { setTrayOnClick, setTrayOnDoubleClick } = require("../../tray");
const notifier = new WindowsToaster({ withFallback: true });
const { Notification, app, ipcMain } = require("electron");
const path = require('path');
//store song controls reference on launch
let controls;
let notificationOnUnpause;
const config = require("./config");
module.exports = (win, unpauseNotification) => {
//Save controls and onPause option
const { playPause, next, previous } = getSongControls(win);
controls = { playPause, next, previous };
notificationOnUnpause = unpauseNotification;
let songControls;
let savedNotification;
let currentUrl;
/** @param {Electron.BrowserWindow} win */
module.exports = (win) => {
songControls = getSongControls(win);
let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
if (app.isPackaged) save_temp_icons();
let savedSongInfo;
let lastUrl;
// Register songInfoCallback
registerCallback(songInfo => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || notificationOnUnpause)) {
currentUrl = songInfo.url;
sendToaster(songInfo);
if (!songInfo.artist && !songInfo.title) return;
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused &&
(songInfo.url !== lastUrl || config.get("unpauseNotification"))
) {
lastUrl = songInfo.url
sendNotification(songInfo);
}
});
win.webContents.once("closed", () => {
deleteNotification()
if (config.get("trayControls")) {
setTrayOnClick(() => {
if (savedNotification) {
savedNotification.close();
savedNotification = undefined;
} else if (savedSongInfo) {
sendNotification({
...savedSongInfo,
elapsedSeconds: currentSeconds
})
}
});
setTrayOnDoubleClick(() => {
if (win.isVisible()) {
win.hide();
} else win.show();
})
}
app.once("before-quit", () => {
savedNotification?.close();
});
changeProtocolHandler(
(cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd]();
if (config.get("refreshOnPlayPause") && (
cmd === 'pause' ||
(cmd === 'play' && !config.get("unpauseNotification"))
)
) {
setImmediate(() =>
sendNotification({
...savedSongInfo,
isPaused: cmd === 'pause',
elapsedSeconds: currentSeconds
})
);
}
}
}
)
}
//delete old notification
let toDelete;
function deleteNotification() {
if (toDelete !== undefined) {
// To remove the notification it has to be done this way
const removeNotif = Object.assign(toDelete, {
remove: toDelete.id
})
notifier.notify(removeNotif)
function sendNotification(songInfo) {
const iconSrc = notificationImage(songInfo);
toDelete = undefined;
savedNotification?.close();
savedNotification = new Notification({
title: songInfo.title || "Playing",
body: songInfo.artist,
icon: iconSrc,
silent: true,
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
toastXml: get_xml(songInfo, iconSrc),
});
savedNotification.on("close", (_) => {
savedNotification = undefined;
});
savedNotification.show();
}
const get_xml = (songInfo, iconSrc) => {
switch (config.get("toastStyle")) {
default:
case ToastStyles.logo:
case ToastStyles.legacy:
return xml_logo(songInfo, iconSrc);
case ToastStyles.banner_top_custom:
return xml_banner_top_custom(songInfo, iconSrc);
case ToastStyles.hero:
return xml_hero(songInfo, iconSrc);
case ToastStyles.banner_bottom:
return xml_banner_bottom(songInfo, iconSrc);
case ToastStyles.banner_centered_bottom:
return xml_banner_centered_bottom(songInfo, iconSrc);
case ToastStyles.banner_centered_top:
return xml_banner_centered_top(songInfo, iconSrc);
};
}
const iconLocation = app.isPackaged ?
path.resolve(app.getPath("userData"), 'icons') :
path.resolve(__dirname, '..', '..', 'assets/media-icons-black');
const display = (kind) => {
if (config.get("toastStyle") === ToastStyles.legacy) {
return `content="${icons[kind]}"`;
} else {
return `\
content="${config.get("hideButtonText") ? "" : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}"
`;
}
}
//New notification
function sendToaster(songInfo) {
deleteNotification();
//download image and get path
let imgSrc = notificationImage(songInfo, true);
toDelete = {
appID: is.dev() ? undefined : "com.github.th-ch.youtube-music",
title: songInfo.title || "Playing",
message: songInfo.artist,
id: parseInt(Math.random() * 1000000, 10),
icon: imgSrc,
actions: [
icons.previous,
songInfo.isPaused ? icons.play : icons.pause,
icons.next
],
sound: false,
};
//send notification
notifier.notify(
toDelete,
(err, data) => {
// Will also wait until notification is closed.
if (err) {
console.log(`ERROR = ${err.toString()}\n DATA = ${data}`);
}
switch (data) {
//buttons
case icons.previous.normalize():
controls.previous();
return;
case icons.next.normalize():
controls.next();
return;
case icons.play.normalize():
controls.playPause();
// dont delete notification on play/pause
toDelete = undefined;
//manually send notification if not sending automatically
if (!notificationOnUnpause) {
songInfo.isPaused = false;
sendToaster(songInfo);
}
return;
case icons.pause.normalize():
controls.playPause();
songInfo.isPaused = true;
toDelete = undefined;
sendToaster(songInfo);
return;
//Native datatype
case "dismissed":
case "timeout":
deleteNotification();
}
}
const getButton = (kind) =>
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
);
const getButtons = (isPaused) => `\
<actions>
${getButton('previous')}
${isPaused ? getButton('play') : getButton('pause')}
${getButton('next')}
</actions>\
`;
const toast = (content, isPaused) => `\
<toast>
<audio silent="true" />
<visual>
<binding template="ToastGeneric">
${content}
</binding>
</visual>
${getButtons(isPaused)}
</toast>`;
const xml_image = ({ title, artist, isPaused }, imgSrc, placement) => toast(`\
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
<text id="1">${title}</text>
<text id="2">${artist}</text>\
`, isPaused);
const xml_logo = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, 'placement="appLogoOverride"');
const xml_hero = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, 'placement="hero"');
const xml_banner_bottom = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, '');
const xml_banner_top_custom = (songInfo, imgSrc) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup>
<text hint-style="body">${songInfo.title}</text>
<text hint-style="captionSubtle">${songInfo.artist}</text>
</subgroup>
${xml_more_data(songInfo)}
</group>\
`, songInfo.isPaused);
const xml_more_data = ({ album, elapsedSeconds, songDuration }) => `\
<subgroup hint-textStacking="bottom">
${album ?
`<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds)} / ${secondsToMinutes(songDuration)}</text>
</subgroup>\
`;
const xml_banner_centered_bottom = ({ title, artist, isPaused }, imgSrc) => toast(`\
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
`, isPaused);
const xml_banner_centered_top = ({ title, artist, isPaused }, imgSrc) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>\
`, isPaused);
const titleFontPicker = (title) => {
if (title.length <= 13) {
return 'Header';
} else if (title.length <= 22) {
return 'Subheader';
} else if (title.length <= 26) {
return 'Title';
} else {
return 'Subtitle';
}
}

View File

@ -1,30 +1,80 @@
const { urgencyLevels, setOption } = require("./utils");
const { urgencyLevels, ToastStyles, snakeToCamel } = require("./utils");
const is = require("electron-is");
const config = require("./config");
module.exports = (win, options) => [
...(is.linux() ?
[{
label: "Notification Priority",
submenu: urgencyLevels.map(level => ({
label: level.name,
type: "radio",
checked: options.urgency === level.value,
click: () => setOption(options, "urgency", level.value)
})),
}] :
[]),
...(is.windows() ?
[{
label: "Interactive Notifications",
type: "checkbox",
checked: options.interactive,
click: (item) => setOption(options, "interactive", item.checked)
}] :
[]),
module.exports = (_win, options) => [
...(is.linux()
? [
{
label: "Notification Priority",
submenu: urgencyLevels.map((level) => ({
label: level.name,
type: "radio",
checked: options.urgency === level.value,
click: () => config.set("urgency", level.value),
})),
},
]
: []),
...(is.windows()
? [
{
label: "Interactive Notifications",
type: "checkbox",
checked: options.interactive,
// doesn't update until restart
click: (item) => config.setAndMaybeRestart("interactive", item.checked),
},
{
// submenu with settings for interactive notifications (name shouldn't be too long)
label: "Interactive Settings",
submenu: [
{
label: "Open/Close on tray click",
type: "checkbox",
checked: options.trayControls,
click: (item) => config.set("trayControls", item.checked),
},
{
label: "Hide Button Text",
type: "checkbox",
checked: options.hideButtonText,
click: (item) => config.set("hideButtonText", item.checked),
},
{
label: "Refresh on Play/Pause",
type: "checkbox",
checked: options.refreshOnPlayPause,
click: (item) => config.set("refreshOnPlayPause", item.checked),
}
]
},
{
label: "Style",
submenu: getToastStyleMenuItems(options)
},
]
: []),
{
label: "Show notification on unpause",
type: "checkbox",
checked: options.unpauseNotification,
click: (item) => setOption(options, "unpauseNotification", item.checked)
click: (item) => config.set("unpauseNotification", item.checked),
},
];
function getToastStyleMenuItems(options) {
const arr = new Array(Object.keys(ToastStyles).length);
// ToastStyles index starts from 1
for (const [name, index] of Object.entries(ToastStyles)) {
arr[index - 1] = {
label: snakeToCamel(name),
type: "radio",
checked: options.toastStyle === index,
click: () => config.set("toastStyle", index),
};
}
return arr;
}

View File

@ -1,10 +1,24 @@
const { setMenuOptions } = require("../../config/plugins");
const path = require("path");
const { app } = require("electron");
const fs = require("fs");
const config = require("./config");
const icon = "assets/youtube-music.png";
const tempIcon = path.join(app.getPath("userData"), "tempIcon.png");
const userData = app.getPath("userData");
const tempIcon = path.join(userData, "tempIcon.png");
const tempBanner = path.join(userData, "tempBanner.png");
const { cache } = require("../../providers/decorators")
module.exports.ToastStyles = {
logo: 1,
banner_centered_top: 2,
hero: 3,
banner_top_custom: 4,
banner_centered_bottom: 5,
banner_bottom: 6,
legacy: 7
}
module.exports.icons = {
play: "\u{1405}", // ᐅ
@ -13,44 +27,67 @@ module.exports.icons = {
previous: "\u{1438}" //
}
module.exports.setOption = (options, option, value) => {
options[option] = value;
setMenuOptions("notifications", options)
}
module.exports.urgencyLevels = [
{ name: "Low", value: "low" },
{ name: "Normal", value: "normal" },
{ name: "High", value: "critical" },
];
module.exports.notificationImage = function (songInfo, saveIcon = false) {
//return local path to temp icon
if (saveIcon && !!songInfo.image) {
try {
fs.writeFileSync(tempIcon,
centerNativeImage(songInfo.image)
.toPNG()
);
} catch (err) {
console.log(`Error writing song icon to disk:\n${err.toString()}`)
return icon;
}
return tempIcon;
}
//else: return image
return songInfo.image
? centerNativeImage(songInfo.image)
: icon
};
function centerNativeImage(nativeImage) {
const nativeImageToLogo = cache((nativeImage) => {
const tempImage = nativeImage.resize({ height: 256 });
const margin = Math.max((tempImage.getSize().width - 256), 0);
const margin = Math.max(tempImage.getSize().width - 256, 0);
return tempImage.crop({
x: Math.round(margin / 2),
y: 0,
width: 256, height: 256
})
width: 256,
height: 256,
});
});
module.exports.notificationImage = (songInfo) => {
if (!songInfo.image) return icon;
if (!config.get("interactive")) return nativeImageToLogo(songInfo.image);
switch (config.get("toastStyle")) {
case module.exports.ToastStyles.logo:
case module.exports.ToastStyles.legacy:
return this.saveImage(nativeImageToLogo(songInfo.image), tempIcon);
default:
return this.saveImage(songInfo.image, tempBanner);
};
};
module.exports.saveImage = cache((img, save_path) => {
try {
fs.writeFileSync(save_path, img.toPNG());
} catch (err) {
console.log(`Error writing song icon to disk:\n${err.toString()}`)
return icon;
}
return save_path;
});
module.exports.save_temp_icons = () => {
for (const kind of Object.keys(module.exports.icons)) {
const destinationPath = path.join(userData, 'icons', `${kind}.png`);
if (fs.existsSync(destinationPath)) continue;
const iconPath = path.resolve(__dirname, "../../assets/media-icons-black", `${kind}.png`);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFile(iconPath, destinationPath, () => { });
}
};
module.exports.snakeToCamel = (str) => {
return str.replace(/([-_][a-z]|^[a-z])/g, (group) =>
group.toUpperCase()
.replace('-', ' ')
.replace('_', ' ')
);
}
module.exports.secondsToMinutes = (seconds) => {
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`;
}

View File

@ -1,37 +0,0 @@
const { Menu, app } = require("electron");
const { setApplicationMenu } = require("../../../menu");
module.exports = (win, options, setOptions, togglePip, isInPip) => {
if (isInPip) {
Menu.setApplicationMenu(Menu.buildFromTemplate([
{
label: "App",
submenu: [
{
label: "Exit Picture in Picture",
click: togglePip,
},
{
label: "Always on top",
type: "checkbox",
checked: options.alwaysOnTop,
click: (item) => {
setOptions({ alwaysOnTop: item.checked });
win.setAlwaysOnTop(item.checked);
},
},
{
label: "Restart",
click: () => {
app.relaunch();
app.quit();
},
},
{ role: "quit" },
],
},
]));
} else {
setApplicationMenu(win);
}
};

View File

@ -3,7 +3,7 @@ const path = require("path");
const { app, ipcMain } = require("electron");
const electronLocalshortcut = require("electron-localshortcut");
const { setOptions, isEnabled } = require("../../config/plugins");
const { setOptions } = require("../../config/plugins");
const { injectCSS } = require("../utils");
let isInPiP = false;
@ -23,15 +23,6 @@ const setLocalOptions = (_options) => {
setOptions("picture-in-picture", _options);
}
const adaptors = [];
const runAdaptors = () => adaptors.forEach(a => a());
if (isEnabled("in-app-menu")) {
let adaptor = require("./adaptors/in-app-menu");
adaptors.push(() => adaptor(win, options, setLocalOptions, togglePiP, isInPiP));
}
const togglePiP = async () => {
isInPiP = !isInPiP;
setLocalOptions({ isInPiP });
@ -47,9 +38,9 @@ const togglePiP = async () => {
win.webContents.on("before-input-event", blockShortcutsInPiP);
win.setMaximizable(false);
win.setFullScreenable(false);
runAdaptors();
win.webContents.send("pip-toggle", true);
app.dock?.hide();
@ -62,9 +53,9 @@ const togglePiP = async () => {
}
} else {
win.webContents.removeListener("before-input-event", blockShortcutsInPiP);
win.setMaximizable(true);
win.setFullScreenable(true);
runAdaptors();
win.webContents.send("pip-toggle", false);
win.setVisibleOnAllWorkspaces(false);
@ -101,9 +92,6 @@ module.exports = (_win, _options) => {
ipcMain.on("picture-in-picture", async () => {
await togglePiP();
});
if (options.hotkey) {
electronLocalshortcut.register(win, options.hotkey, togglePiP);
}
};
module.exports.setOptions = setLocalOptions;

View File

@ -1,21 +1,40 @@
const { ipcRenderer } = require("electron");
const { toKeyEvent } = require("keyboardevent-from-electron-accelerator");
const keyEventAreEqual = require("keyboardevents-areequal");
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath } = require("../utils");
function $(selector) { return document.querySelector(selector); }
let useNativePiP = false;
let menu = null;
const pipButton = ElementFromFile(
templatePath(__dirname, "picture-in-picture.html")
);
// will also clone
function replaceButton(query, button) {
const svg = button.querySelector("#icon svg").cloneNode(true);
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
newButton.querySelector("#icon").appendChild(svg);
return newButton;
}
function cloneButton(query) {
replaceButton(query, $(query));
return $(query);
}
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) return;
}
if (menu.contains(pipButton)) return;
if (menu.contains(pipButton) || !menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar')) return;
const menuUrl = $(
'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint'
)?.href;
@ -24,15 +43,28 @@ const observer = new MutationObserver(() => {
menu.prepend(pipButton);
});
global.togglePictureInPicture = () => {
global.togglePictureInPicture = async () => {
if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = $("video");
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture.call(document)
: video.requestPictureInPicture.call(video);
try {
await togglePiP();
$("#icon").click(); // Close the menu
return true;
} catch {}
}
ipcRenderer.send("picture-in-picture");
return false;
};
const listenForToggle = () => {
const originalExitButton = $(".exit-fullscreen-button");
const clonedExitButton = originalExitButton.cloneNode(true);
clonedExitButton.onclick = () => togglePictureInPicture();
const appLayout = $("ytmusic-app-layout");
const expandMenu = $('#expanding-menu');
const middleControls = $('.middle-controls');
@ -42,9 +74,12 @@ const listenForToggle = () => {
const player = $('#player');
const onPlayerDblClick = player.onDoubleClick_;
ipcRenderer.on('pip-toggle', (_, isPip) => {
const titlebar = $(".cet-titlebar");
ipcRenderer.on("pip-toggle", (_, isPip) => {
if (isPip) {
$(".exit-fullscreen-button").replaceWith(clonedExitButton);
replaceButton(".exit-fullscreen-button", originalExitButton).onclick =
() => togglePictureInPicture();
player.onDoubleClick_ = () => {};
expandMenu.onmouseleave = () => middleControls.click();
if (!playerPage.playerPageOpen_) {
@ -52,32 +87,33 @@ const listenForToggle = () => {
}
fullScreenButton.click();
appLayout.classList.add("pip");
if (titlebar) titlebar.style.display = "none";
} else {
$(".exit-fullscreen-button").replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu.onmouseleave = undefined;
originalExitButton.click();
appLayout.classList.remove("pip");
if (titlebar) titlebar.style.display = "flex";
}
});
}
function observeMenu(options) {
useNativePiP = options.useNativePiP;
document.addEventListener(
"apiLoaded",
() => {
listenForToggle();
const minButton = $(".player-minimize-button");
// remove native listeners
minButton.replaceWith(minButton.cloneNode(true));
$(".player-minimize-button").onclick = () => {
global.togglePictureInPicture();
setTimeout(() => $('#player').click());
cloneButton(".player-minimize-button").onclick = async () => {
await global.togglePictureInPicture();
setTimeout(() => $("#player").click());
};
// allows easily closing the menu by programmatically clicking outside of it
$("#expanding-menu").removeAttribute("no-cancel-on-outside-click");
// TODO: think about wether an additional button in songMenu is needed
// TODO: think about wether an additional button in songMenu is needed
observer.observe($("ytmusic-popup-container"), {
childList: true,
subtree: true,
@ -87,4 +123,18 @@ function observeMenu(options) {
);
}
module.exports = observeMenu;
module.exports = (options) => {
observeMenu(options);
if (options.hotkey) {
const hotkeyEvent = toKeyEvent(options.hotkey);
window.addEventListener("keydown", (event) => {
if (
keyEventAreEqual(event, hotkeyEvent) &&
!$("ytmusic-search-box").opened
) {
togglePictureInPicture();
}
});
}
};

View File

@ -45,16 +45,24 @@ module.exports = (win, options) => [
}],
...promptOptions()
}, win)
if (output) {
const { value, accelerator } = output[0];
setOptions({ [value]: accelerator });
item.checked = !!accelerator;
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
},
},
{
label: "Use native PiP",
type: "checkbox",
checked: options.useNativePiP,
click: (item) => {
setOptions({ useNativePiP: item.checked });
},
}
];

View File

@ -3,9 +3,9 @@ ytmusic-app-layout.pip ytmusic-player-bar svg,
ytmusic-app-layout.pip ytmusic-player-bar .time-info,
ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
filter: drop-shadow(2px 4px 6px black);
color: white !important;
fill: white !important;
filter: drop-shadow(2px 4px 6px black);
color: white !important;
fill: white !important;
}
/* improve the style of the player bar expanding menu */
@ -20,6 +20,23 @@ ytmusic-app-layout.pip ytmusic-player-expanding-menu {
top: 22px !important;
}
/* make player-bar not draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip ytmusic-player-bar {
-webkit-app-region: no-drag !important;
}
/* make player draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip #player {
-webkit-app-region: drag !important;
}
/* remove info, thumbnail and menu from player-bar */
ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
display: none !important;
}
/* disable the video-toggle button when in PiP mode */
ytmusic-app-layout.pip .video-switch-button {
display: none !important;

View File

@ -1,5 +1,6 @@
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath } = require("../utils");
const { singleton } = require("../../providers/decorators")
function $(selector) { return document.querySelector(selector); }
@ -22,7 +23,16 @@ const updatePlayBackSpeed = () => {
};
let menu;
let observingSlider = false;
const setupSliderListener = singleton(() => {
$('#playback-speed-slider').addEventListener('immediate-value-changed', e => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
})
});
const observePopupContainer = () => {
const observer = new MutationObserver(() => {
@ -30,12 +40,9 @@ const observePopupContainer = () => {
menu = getSongMenu();
}
if (menu && menu.lastElementChild.lastElementChild.innerText.startsWith('Stats') && !menu.contains(slider)) {
if (menu && menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar') && !menu.contains(slider)) {
menu.prepend(slider);
if (!observingSlider) {
setupSliderListener();
observingSlider = true;
}
setupSliderListener();
}
});
@ -68,16 +75,6 @@ const setupWheelListener = () => {
})
}
function setupSliderListener() {
$('#playback-speed-slider').addEventListener('immediate-value-changed', e => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
})
}
function forcePlaybackRate(e) {
if (e.target.playbackRate !== playbackSpeed) {
e.target.playbackRate = playbackSpeed

View File

@ -4,6 +4,8 @@ const { setOptions, setMenuOptions, isEnabled } = require("../../config/plugins"
function $(selector) { return document.querySelector(selector); }
const { debounce } = require("../../providers/decorators");
let api, options;
module.exports = (_options) => {
@ -16,7 +18,27 @@ module.exports = (_options) => {
}, { once: true, passive: true })
};
module.exports.moveVolumeHud = moveVolumeHud;
//without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => {
setOptions("precise-volume", options);
}, 1000);
module.exports.moveVolumeHud = debounce((showVideo) => {
const volumeHud = $("#volumeHud");
if (!volumeHud) return;
volumeHud.style.top = showVideo
? `${($("ytmusic-player").clientHeight - $("video").clientHeight) / 2}px`
: 0;
}, 250);
const hideVolumeHud = debounce((volumeHud) => {
volumeHud.style.opacity = 0;
}, 2000);
const hideVolumeSlider = debounce((slider) => {
slider.classList.remove("on-hover");
}, 2500);
/** Restore saved volume and setup tooltip */
function firstRun() {
@ -67,33 +89,14 @@ function injectVolumeHud(noVid) {
}
}
let hudMoveTimeout;
function moveVolumeHud(showVideo) {
clearTimeout(hudMoveTimeout);
const volumeHud = $('#volumeHud');
if (!volumeHud) return;
hudMoveTimeout = setTimeout(() => {
volumeHud.style.top = showVideo ? `${($('ytmusic-player').clientHeight - $('video').clientHeight) / 2}px` : 0;
}, 250)
}
let hudFadeTimeout;
function showVolumeHud(volume) {
let volumeHud = $("#volumeHud");
const volumeHud = $("#volumeHud");
if (!volumeHud) return;
volumeHud.textContent = volume + '%';
volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = 1;
if (hudFadeTimeout) {
clearTimeout(hudFadeTimeout);
}
hudFadeTimeout = setTimeout(() => {
volumeHud.style.opacity = 0;
hudFadeTimeout = null;
}, 2000);
hideVolumeHud(volumeHud);
}
/** Add onwheel event to video player */
@ -110,17 +113,6 @@ function saveVolume(volume) {
writeOptions();
}
//without this function it would rewrite config 20 time when volume change by 20
let writeTimeout;
function writeOptions() {
if (writeTimeout) clearTimeout(writeTimeout);
writeTimeout = setTimeout(() => {
setOptions("precise-volume", options);
writeTimeout = null;
}, 1000)
}
/** Add onwheel event to play bar and also track if play bar is hovered*/
function setupPlaybar() {
const playerbar = $("ytmusic-player-bar");
@ -199,23 +191,12 @@ function updateVolumeSlider() {
}
}
let volumeHoverTimeoutID;
function showVolumeSlider() {
const slider = $("#volume-slider");
// This class display the volume slider if not in minimized mode
slider.classList.add("on-hover");
// Reset timeout if previous one hasn't completed
if (volumeHoverTimeoutID) {
clearTimeout(volumeHoverTimeoutID);
}
// Timeout to remove volume preview after 3 seconds if playbar isn't hovered
volumeHoverTimeoutID = setTimeout(() => {
volumeHoverTimeoutID = null;
if (!$("ytmusic-player-bar").classList.contains("on-hover")) {
slider.classList.remove("on-hover");
}
}, 3000);
hideVolumeSlider(slider);
}
// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small)
@ -235,6 +216,7 @@ function setTooltip(volume) {
function setupLocalArrowShortcuts() {
if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => {
if ($('ytmusic-search-box').opened) return;
switch (event.code) {
case "ArrowUp":
event.preventDefault();

View File

@ -18,9 +18,10 @@ function setupMPRIS() {
return player;
}
/** @param {Electron.BrowserWindow} win */
function registerMPRIS(win) {
const songControls = getSongControls(win);
const { playPause, next, previous, volumeMinus10, volumePlus10 } = songControls;
const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = songControls;
try {
const secToMicro = n => Math.round(Number(n) * 1e6);
const microToSec = n => Math.round(Number(n) / 1e6);
@ -30,38 +31,35 @@ function registerMPRIS(win) {
const player = setupMPRIS();
ipcMain.on("apiLoaded", () => {
win.webContents.send("setupSeekedListener", "mpris");
win.webContents.send("setupTimeChangedListener", "mpris");
win.webContents.send("setupRepeatChangedListener", "mpris");
win.webContents.send("setupVolumeChangedListener", "mpris");
});
ipcMain.on('seeked', (_, t) => player.seeked(secToMicro(t)));
let currentSeconds = 0;
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
let currentLoopStatus = undefined;
let manuallySwitchingStatus = false;
ipcMain.on("repeatChanged", (_, mode) => {
if (manuallySwitchingStatus)
return;
if (mode === "Repeat off")
currentLoopStatus = "None";
else if (mode === "Repeat one")
currentLoopStatus = "Track";
else if (mode === "Repeat all")
currentLoopStatus = "Playlist";
player.loopStatus = currentLoopStatus;
if (mode === "NONE")
player.loopStatus = mpris.LOOP_STATUS_NONE;
else if (mode === "ONE") //MPRIS Playlist and Track Codes are switched to look the same as yt-music icons
player.loopStatus = mpris.LOOP_STATUS_PLAYLIST;
else if (mode === "ALL")
player.loopStatus = mpris.LOOP_STATUS_TRACK;
});
player.on("loopStatus", (status) => {
// switchRepeat cycles between states in that order
const switches = ["None", "Playlist", "Track"];
const currentIndex = switches.indexOf(currentLoopStatus);
const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK];
const currentIndex = switches.indexOf(player.loopStatus);
const targetIndex = switches.indexOf(status);
// Get a delta in the range [0,2]
const delta = (targetIndex - currentIndex + 3) % 3;
manuallySwitchingStatus = true;
songControls.switchRepeat(delta);
manuallySwitchingStatus = false;
})
player.getPosition = () => secToMicro(currentSeconds)
@ -72,19 +70,19 @@ function registerMPRIS(win) {
});
player.on("play", () => {
if (player.playbackStatus !== 'Playing') {
player.playbackStatus = 'Playing';
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PLAYING) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PLAYING;
playPause()
}
});
player.on("pause", () => {
if (player.playbackStatus !== 'Paused') {
player.playbackStatus = 'Paused';
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PAUSED) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PAUSED;
playPause()
}
});
player.on("playpause", () => {
player.playbackStatus = player.playbackStatus === 'Playing' ? "Paused" : "Playing";
player.playbackStatus = player.playbackStatus === mpris.PLAYBACK_STATUS_PLAYING ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
playPause();
});
@ -94,40 +92,67 @@ function registerMPRIS(win) {
player.on('seek', seekBy);
player.on('position', seekTo);
ipcMain.on('volumeChanged', (_, value) => {
player.volume = value;
player.on('shuffle', (enableShuffle) => {
shuffle();
});
player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value.
win.webContents.send('setVolume', newVolume)
} else {
// With keyboard shortcuts we can only change the volume in increments of 10, so round it.
const deltaVolume = Math.round((newVolume - player.volume) / 10);
if (deltaVolume > 0) {
for (let i = 0; i < deltaVolume; i++)
volumePlus10();
let mprisVolNewer = false;
let autoUpdate = false;
ipcMain.on('volumeChanged', (_, newVol) => {
if (parseInt(player.volume * 100) !== newVol) {
if (mprisVolNewer) {
mprisVolNewer = false;
autoUpdate = false;
} else {
for (let i = 0; i < -deltaVolume; i++)
volumeMinus10();
autoUpdate = true;
player.volume = parseFloat((newVol / 100).toFixed(2));
mprisVolNewer = false;
autoUpdate = false;
}
}
});
registerCallback(songInfo => {
if (player) {
const data = {
'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc,
'xesam:title': songInfo.title,
'xesam:artist': [songInfo.artist],
player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value.
let newVol = parseInt(newVolume * 100);
if (parseInt(player.volume * 100) !== newVol) {
if (!autoUpdate) {
mprisVolNewer = true;
autoUpdate = false;
win.webContents.send('setVolume', newVol);
}
}
} else {
// With keyboard shortcuts we can only change the volume in increments of 10, so round it.
let deltaVolume = Math.round((newVolume - player.volume) * 10);
while (deltaVolume !== 0 && deltaVolume > 0) {
volumePlus10();
player.volume = player.volume + 0.1;
deltaVolume--;
}
while (deltaVolume !== 0 && deltaVolume < 0) {
volumeMinus10();
player.volume = player.volume - 0.1;
deltaVolume++;
}
}
});
registerCallback(songInfo => {
if (player) {
const data = {
'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc,
'xesam:title': songInfo.title,
'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist],
'mpris:trackid': '/'
};
if (songInfo.album) data['xesam:album'] = songInfo.album;
player.metadata = data;
player.seeked(secToMicro(songInfo.elapsedSeconds))
player.playbackStatus = songInfo.isPaused ? "Paused" : "Playing"
player.seeked(secToMicro(songInfo.elapsedSeconds));
player.playbackStatus = songInfo.isPaused ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
}
})

View File

@ -1,37 +1,112 @@
const hark = require("hark/hark.bundle.js");
module.exports = () => {
module.exports = (options) => {
let isSilent = false;
let hasAudioStarted = false;
document.addEventListener("apiLoaded", () => {
const video = document.querySelector("video");
const speechEvents = hark(video, {
threshold: -100, // dB (-100 = absolute silence, 0 = loudest)
interval: 2, // ms
});
const skipSilence = () => {
if (isSilent && !video.paused) {
video.currentTime += 0.2; // in s
}
};
const smoothing = 0.1;
const threshold = -100; // dB (-100 = absolute silence, 0 = loudest)
const interval = 2; // ms
const history = 10;
const speakingHistory = Array(history).fill(0);
speechEvents.on("speaking", function () {
isSilent = false;
});
document.addEventListener(
"audioCanPlay",
(e) => {
const video = document.querySelector("video");
const audioContext = e.detail.audioContext;
const sourceNode = e.detail.audioSource;
speechEvents.on("stopped_speaking", function () {
if (!(video.paused || video.seeking || video.ended)) {
isSilent = true;
// Use an audio analyser similar to Hark
// https://github.com/otalk/hark/blob/master/hark.bundle.js
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing;
const fftBins = new Float32Array(analyser.frequencyBinCount);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
const looper = () => {
setTimeout(() => {
const currentVolume = getMaxVolume(analyser, fftBins);
let history = 0;
if (currentVolume > threshold && isSilent) {
// trigger quickly, short history
for (
let i = speakingHistory.length - 3;
i < speakingHistory.length;
i++
) {
history += speakingHistory[i];
}
if (history >= 2) {
// Not silent
isSilent = false;
hasAudioStarted = true;
}
} else if (currentVolume < threshold && !isSilent) {
for (let i = 0; i < speakingHistory.length; i++) {
history += speakingHistory[i];
}
if (history == 0) {
// Silent
if (
!(
video.paused ||
video.seeking ||
video.ended ||
video.muted ||
video.volume === 0
)
) {
isSilent = true;
skipSilence();
}
}
}
speakingHistory.shift();
speakingHistory.push(0 + (currentVolume > threshold));
looper();
}, interval);
};
looper();
const skipSilence = () => {
if (options.onlySkipBeginning && hasAudioStarted) {
return;
}
if (isSilent && !video.paused) {
video.currentTime += 0.2; // in s
}
};
video.addEventListener("play", function () {
hasAudioStarted = false;
skipSilence();
}
});
});
video.addEventListener("play", function () {
skipSilence();
});
video.addEventListener("seeked", function () {
skipSilence();
});
});
video.addEventListener("seeked", function () {
hasAudioStarted = false;
skipSilence();
});
},
{
passive: true,
}
);
};
function getMaxVolume(analyser, fftBins) {
var maxVolume = -Infinity;
analyser.getFloatFrequencyData(fftBins);
for (var i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
}
}
return maxVolume;
}

View File

@ -32,22 +32,22 @@ function setThumbar(win, songInfo) {
win.setThumbarButtons([
{
tooltip: 'Previous',
icon: get('backward.png'),
icon: get('previous'),
click() { controls.previous(win.webContents); }
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: songInfo.isPaused ? get('play.png') : get('pause.png'),
icon: songInfo.isPaused ? get('play') : get('pause'),
click() { controls.playPause(win.webContents); }
}, {
tooltip: 'Next',
icon: get('forward.png'),
icon: get('next'),
click() { controls.next(win.webContents); }
}
]);
}
// Util
function get(file) {
return path.join(__dirname, "assets", file);
function get(kind) {
return path.join(__dirname, "../../assets/media-icons-black", `${kind}.png`);
}

View File

@ -59,11 +59,11 @@ const touchBar = new TouchBar({
});
module.exports = (win) => {
const { playPause, next, previous, like, dislike } = getSongControls(win);
const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback
win.once("ready-to-show", () => {
controls = [previous, playPause, next, like, dislike];
controls = [previous, playPause, next, dislike, like];
// Register the callback
registerCallback((songInfo) => {

View File

@ -5,6 +5,7 @@ const registerCallback = require("../../providers/song-info");
const secToMilisec = t => Math.round(Number(t) * 1e3);
const data = {
cover: '',
cover_url: '',
title: '',
artists: [],
@ -27,7 +28,9 @@ const post = async (data) => {
fetch(url, { method: 'POST', headers, body: JSON.stringify({ data }) }).catch(e => console.log(`Error: '${e.code || e.errno}' - when trying to access obs-tuna webserver at port ${port}`));
}
/** @param {Electron.BrowserWindow} win */
module.exports = async (win) => {
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', async (_, t) => {
if (!data.title) return;
data.progress = secToMilisec(t);
@ -41,6 +44,7 @@ module.exports = async (win) => {
data.duration = secToMilisec(songInfo.songDuration)
data.progress = secToMilisec(songInfo.elapsedSeconds)
data.cover = songInfo.imageSrc;
data.cover_url = songInfo.imageSrc;
data.album_url = songInfo.imageSrc;
data.title = songInfo.title;

View File

@ -2,6 +2,10 @@
align-items: unset !important;
}
#main-panel {
position: relative;
}
.video-switch-button {
z-index: 999;
box-sizing: border-box;

View File

@ -38,7 +38,7 @@ function setup(e) {
player = $('ytmusic-player');
video = $('video');
$('ytmusic-player-page').prepend(switchButtonDiv);
$('#main-panel').append(switchButtonDiv);
if (options.hideVideo) {
$('.video-switch-button-checkbox').checked = false;
@ -58,6 +58,21 @@ function setup(e) {
video.addEventListener('srcChanged', videoStarted);
observeThumbnail();
switch (options.align) {
case "right": {
switchButtonDiv.style.left = "calc(100% - 240px)";
return;
}
case "middle": {
switchButtonDiv.style.left = "calc(50% - 120px)";
return;
}
default:
case "left": {
switchButtonDiv.style.left = "0px";
}
}
}
function changeDisplay(showVideo) {

View File

@ -33,6 +33,38 @@ module.exports = (win, options) => [
},
]
},
{
label: "Alignment",
submenu: [
{
label: "Left",
type: "radio",
checked: options.align === 'left',
click: () => {
options.align = 'left';
setMenuOptions("video-toggle", options);
}
},
{
label: "Middle",
type: "radio",
checked: options.align === 'middle',
click: () => {
options.align = 'middle';
setMenuOptions("video-toggle", options);
}
},
{
label: "Right",
type: "radio",
checked: options.align === 'right',
click: () => {
options.align = 'right';
setMenuOptions("video-toggle", options);
}
},
]
},
{
label: "Force Remove Video Tab",
type: "checkbox",

View File

@ -0,0 +1,6 @@
const { injectCSS } = require("../utils");
const path = require("path");
module.exports = (win, options) => {
injectCSS(win.webContents, path.join(__dirname, "empty-player.css"));
};

View File

@ -0,0 +1,9 @@
#player {
margin: 0 !important;
background: black;
}
#song-image,
#song-video {
display: none !important;
}

View File

@ -0,0 +1,61 @@
const defaultConfig = require("../../config/defaults");
module.exports = (options) => {
const optionsWithDefaults = {
...defaultConfig.plugins.visualizer,
...options,
};
const VisualizerType = require(`./visualizers/${optionsWithDefaults.type}`);
document.addEventListener(
"audioCanPlay",
(e) => {
const video = document.querySelector("video");
const visualizerContainer = document.querySelector("#player");
let canvas = document.getElementById("visualizer");
if (!canvas) {
canvas = document.createElement("canvas");
canvas.id = "visualizer";
canvas.style.position = "absolute";
canvas.style.background = "black";
visualizerContainer.append(canvas);
}
const resizeCanvas = () => {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
};
resizeCanvas();
const gainNode = e.detail.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
const visualizer = new VisualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
optionsWithDefaults[optionsWithDefaults.type]
);
const resizeVisualizer = (width, height) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
});
});
visualizerContainerObserver.observe(visualizerContainer);
visualizer.render();
},
{ passive: true }
);
};

View File

@ -0,0 +1,23 @@
const { readdirSync } = require("fs");
const path = require("path");
const { setMenuOptions } = require("../../config/plugins");
const visualizerTypes = readdirSync(path.join(__dirname, "visualizers")).map(
(filename) => path.parse(filename).name
);
module.exports = (win, options) => [
{
label: "Type",
submenu: visualizerTypes.map((visualizerType) => ({
label: visualizerType,
type: "radio",
checked: options.type === visualizerType,
click: () => {
options.type = visualizerType;
setMenuOptions("visualizer", options);
},
})),
},
];

View File

@ -0,0 +1,46 @@
const butterchurn = require("butterchurn");
const butterchurnPresets = require("butterchurn-presets");
const presets = butterchurnPresets.getPresets();
class ButterchurnVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = butterchurn.default.createVisualizer(
audioContext,
canvas,
{
width: canvas.width,
height: canvas.height,
}
);
const preset = presets[options.preset];
this.visualizer.loadPreset(preset, options.blendTimeInSeconds);
this.visualizer.connectAudio(audioNode);
this.renderingFrequencyInMs = options.renderingFrequencyInMs;
}
resize(width, height) {
this.visualizer.setRendererSize(width, height);
}
render() {
const renderVisualizer = () => {
requestAnimationFrame(() => renderVisualizer());
this.visualizer.render();
};
setTimeout(renderVisualizer(), this.renderingFrequencyInMs);
}
}
module.exports = ButterchurnVisualizer;

View File

@ -0,0 +1,33 @@
const Vudio = require("vudio/umd/vudio");
class VudioVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
});
}
resize(width, height) {
this.visualizer.setOption({
width: width,
height: height,
});
}
render() {
this.visualizer.dance();
}
}
module.exports = VudioVisualizer;

View File

@ -0,0 +1,31 @@
const { Wave } = require("@foobar404/wave");
class WaveVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas
);
options.animations.forEach((animation) => {
this.visualizer.addAnimation(
eval(`new this.visualizer.animations.${animation.type}(
${JSON.stringify(animation.config)}
)`)
);
});
}
resize(width, height) {}
render() {}
}
module.exports = WaveVisualizer;

View File

@ -1,12 +1,16 @@
require("./providers/front-logger")();
const config = require("./config");
const { fileExists } = require("./plugins/utils");
const setupSongInfo = require("./providers/song-info-front");
const { setupSongControls } = require("./providers/song-controls-front");
const { ipcRenderer } = require("electron");
const is = require("electron-is");
const { startingPages } = require("./providers/extracted-data");
const plugins = config.plugins.getEnabled();
const $ = document.querySelector.bind(document);
let api;
plugins.forEach(async ([plugin, options]) => {
@ -69,17 +73,24 @@ document.addEventListener("DOMContentLoaded", () => {
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
setInterval(() => window._lact = Date.now(), 900000);
// setup back to front logger
if (is.dev()) {
ipcRenderer.on("log", (_event, log) => {
console.log(JSON.parse(log));
});
}
});
function listenForApiLoad() {
api = document.querySelector('#movie_player');
api = $('#movie_player');
if (api) {
onApiLoaded();
return;
}
const observer = new MutationObserver(() => {
api = document.querySelector('#movie_player');
api = $('#movie_player');
if (api) {
observer.disconnect();
onApiLoaded();
@ -90,21 +101,61 @@ function listenForApiLoad() {
}
function onApiLoaded() {
const video = $("video");
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination);
video.addEventListener(
"loadstart",
() => {
// Emit "audioCanPlay" for each video
video.addEventListener(
"canplaythrough",
() => {
document.dispatchEvent(
new CustomEvent("audioCanPlay", {
detail: {
audioContext: audioContext,
audioSource: audioSource,
},
})
);
},
{ once: true }
);
},
{ passive: true }
);
document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api }));
ipcRenderer.send('apiLoaded');
// Navigate to "Starting page"
const startingPage = config.get("options.startingPage");
if (startingPage && startingPages[startingPage]) {
$('ytmusic-app')?.navigate_(startingPages[startingPage]);
}
// Remove upgrade button
if (config.get("options.removeUpgradeButton")) {
const upgradeButton = document.querySelector('ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]')
const upgradeButton = $('ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]')
if (upgradeButton) {
upgradeButton.style.display = "none";
}
}
// Force show like buttons
if (config.get("options.ForceShowLikeButtons")) {
const likeButtons = document.querySelector('ytmusic-like-button-renderer')
// Hide / Force show like buttons
const likeButtonsOptions = config.get("options.likeButtons");
if (likeButtonsOptions) {
const likeButtons = $("ytmusic-like-button-renderer");
if (likeButtons) {
likeButtons.style.display = 'inherit';
likeButtons.style.display =
{
hide: "none",
force: "inherit",
}[likeButtonsOptions] || "";
}
}
}

View File

@ -1,12 +1,10 @@
const path = require("path");
const is = require("electron-is");
const { app, BrowserWindow, ipcMain, ipcRenderer } = require("electron");
const config = require("../config");
module.exports.restart = () => {
is.main() ? restart() : ipcRenderer.send('restart');
process.type === 'browser' ? restart() : ipcRenderer.send('restart');
};
module.exports.setupAppControls = () => {
@ -21,3 +19,16 @@ function restart() {
// execPath will be undefined if not running portable app, resulting in default behavior
app.quit();
}
function sendToFront(channel, ...args) {
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send(channel, ...args);
});
}
module.exports.sendToFront =
process.type === 'browser'
? sendToFront
: () => {
console.error('sendToFront called from renderer');
};

113
providers/decorators.js Normal file
View File

@ -0,0 +1,113 @@
module.exports = {
singleton,
debounce,
cache,
throttle,
memoize,
retry,
};
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function singleton(fn) {
let called = false;
return (...args) => {
if (called) return;
called = true;
return fn(...args);
};
}
/**
* @template T
* @param {T} fn
* @param {number} delay
* @returns {T}
*/
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function cache(fn) {
let lastArgs;
let lastResult;
return (...args) => {
if (
args.length !== lastArgs?.length ||
args.some((arg, i) => arg !== lastArgs[i])
) {
lastArgs = args;
lastResult = fn(...args);
}
return lastResult;
};
}
/*
the following are currently unused, but potentially useful in the future
*/
/**
* @template T
* @param {T} fn
* @param {number} delay
* @returns {T}
*/
function throttle(fn, delay) {
let timeout;
return (...args) => {
if (timeout) return;
timeout = setTimeout(() => {
timeout = undefined;
fn(...args);
}, delay);
};
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function retry(fn, { retries = 3, delay = 1000 } = {}) {
return (...args) => {
try {
return fn(...args);
} catch (e) {
if (retries > 0) {
retries--;
setTimeout(() => retry(fn, { retries, delay })(...args), delay);
} else {
throw e;
}
}
};
}

View File

@ -0,0 +1,23 @@
const startingPages = {
Default: '',
Home: 'FEmusic_home',
Explore: 'FEmusic_explore',
'New Releases': 'FEmusic_new_releases',
Charts: 'FEmusic_charts',
'Moods & Genres': 'FEmusic_moods_and_genres',
Library: 'FEmusic_library_landing',
Playlists: 'FEmusic_liked_playlists',
Songs: 'FEmusic_liked_videos',
Albums: 'FEmusic_liked_albums',
Artists: 'FEmusic_library_corpus_track_artists',
'Subscribed Artists': 'FEmusic_library_corpus_artists',
Uploads: 'FEmusic_library_privately_owned_landing',
'Uploaded Playlists': 'FEmusic_liked_playlists',
'Uploaded Songs': 'FEmusic_library_privately_owned_tracks',
'Uploaded Albums': 'FEmusic_library_privately_owned_releases',
'Uploaded Artists': 'FEmusic_library_privately_owned_artists',
};
module.exports = {
startingPages,
};

View File

@ -1,13 +0,0 @@
const { ipcRenderer } = require("electron");
function logToString(log) {
return (typeof log === "string") ?
log :
JSON.stringify(log, null, "\t");
}
module.exports = () => {
ipcRenderer.on("log", (_event, log) => {
console.log(logToString(log));
});
};

View File

@ -0,0 +1,44 @@
const { app } = require("electron");
const path = require("path");
const getSongControls = require("./song-controls");
const APP_PROTOCOL = "youtubemusic";
let protocolHandler;
function setupProtocolHandler(win) {
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(
APP_PROTOCOL,
process.execPath,
[path.resolve(process.argv[1])]
);
} else {
app.setAsDefaultProtocolClient(APP_PROTOCOL)
}
const songControls = getSongControls(win);
protocolHandler = (cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd]();
}
}
}
function handleProtocol(cmd) {
protocolHandler(cmd);
}
function changeProtocolHandler(f) {
protocolHandler = f;
}
module.exports = {
APP_PROTOCOL,
setupProtocolHandler,
handleProtocol,
changeProtocolHandler,
};

View File

@ -1,13 +1,8 @@
const { ipcRenderer } = require("electron");
const config = require("../config");
const is = require("electron-is");
module.exports.setupSongControls = () => {
document.addEventListener('apiLoaded', e => {
ipcRenderer.on("seekTo", (_, t) => e.detail.seekTo(t));
ipcRenderer.on("seekBy", (_, t) => e.detail.seekBy(t));
if (is.linux() && config.plugins.isEnabled('shortcuts')) { // MPRIS Enabled
document.querySelector('video').addEventListener('seeked', v => ipcRenderer.send('seeked', v.target.currentTime));
}
}, { once: true, passive: true })
};

View File

@ -8,7 +8,7 @@ const pressKey = (window, key, modifiers = []) => {
};
module.exports = (win) => {
return {
const commands = {
// Playback
previous: () => pressKey(win, "k"),
next: () => pressKey(win, "j"),
@ -21,8 +21,7 @@ module.exports = (win) => {
go1sForward: () => pressKey(win, "l", ["shift"]),
shuffle: () => pressKey(win, "s"),
switchRepeat: (n = 1) => {
for (let i = 0; i < n; i++)
pressKey(win, "r");
for (let i = 0; i < n; i++) pressKey(win, "r");
},
// General
volumeMinus10: () => pressKey(win, "-"),
@ -50,4 +49,9 @@ module.exports = (win) => {
search: () => pressKey(win, "/"),
showShortcuts: () => pressKey(win, "/", ["shift"]),
};
return {
...commands,
play: commands.playPause,
pause: commands.playPause
};
};

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