Compare commits

..

289 Commits

Author SHA1 Message Date
ca04c4561b Bump version to 3.0.2 2023-12-03 03:30:08 +09:00
7d8fbf49a8 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.6% (69 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hant/
2023-12-02 19:29:34 +01:00
75e15b948d Translated using Weblate (Chinese (Traditional))
Currently translated at 23.6% (69 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hant/
2023-12-02 19:29:28 +01:00
125b69fd75 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.6% (69 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hant/
2023-12-02 19:29:28 +01:00
a68d6b64dd Translated using Weblate (French)
Currently translated at 8.9% (26 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fr/
2023-12-02 19:29:28 +01:00
a60d4264dc fix: pnpm install doesn't refresh lock file 2023-12-03 03:16:07 +09:00
9e2c6b1afa fix: update pnpm-lock.yaml 2023-12-03 03:07:11 +09:00
14965a93e9 fix: simple-youtube-age-restriction-bypass 2023-12-03 02:49:17 +09:00
f62664b6a5 fix(adblocker): fix In-Player adblocker
- resolves #1478
2023-12-03 02:06:49 +09:00
60cb7f32f1 fix(in-app-menu/i18n): fix hide-dom-window-controls 2023-12-03 01:00:33 +09:00
008b3ad710 Translated using Weblate (Chinese (Traditional))
Currently translated at 1.0% (3 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hant/
2023-12-02 16:59:02 +01:00
8cae64f496 fix(menu): crash on linux
- resolves #1477
2023-12-03 00:54:36 +09:00
5cdc1bc762 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/
2023-12-02 16:29:40 +01:00
15c455105b Translated using Weblate (French)
Currently translated at 8.5% (25 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fr/
2023-12-02 16:29:40 +01:00
14407a98c9 Translated using Weblate (English)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/en/
2023-12-02 16:29:40 +01:00
0d004d5caf Added translation using Weblate (Chinese (Traditional)) 2023-12-02 16:29:40 +01:00
4b75a2405c Update changelog for v3.0.1 2023-12-02 14:32:40 +00:00
1f7e28b6fb Bump version to 3.0.1 (hotfix) 2023-12-02 23:23:26 +09:00
c41b2ce861 fix: if locale is not present, set to unspecified 2023-12-02 23:23:13 +09:00
7f02afc5a6 Translated using Weblate (French)
Currently translated at 3.4% (10 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fr/
2023-12-02 14:14:53 +00:00
496b3ffc1b fix(adblocker): remove unused statement 2023-12-02 23:14:04 +09:00
e9a395f67a hotfix(adblocker): fix #1475 2023-12-02 23:13:21 +09:00
0660f0b7ce Translated using Weblate (German)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 14:41:50 +01:00
3ac09b9dc1 Added translation using Weblate (French) 2023-12-02 14:41:50 +01:00
fe4904a4af fix(index): i18n translate 2023-12-02 22:35:49 +09:00
d8c8bd17ec Update changelog for v3.0.0 2023-12-02 13:21:02 +00:00
e9d4d5ba14 Bump version to 3.0.0 2023-12-02 22:12:51 +09:00
5b2e69588f Translated using Weblate (Korean)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-02 14:11:43 +01:00
c1591402a0 Translated using Weblate (Korean)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-02 13:11:07 +00:00
e2e9c03895 chore(deps): update dependency vite-plugin-inspect to v0.8.1 2023-12-02 22:06:08 +09:00
deac4ef56b Translated using Weblate (German)
Currently translated at 99.6% (291 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 14:03:09 +01:00
7c39e658ce Translated using Weblate (German)
Currently translated at 99.6% (291 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 13:49:31 +01:00
6b026f57bc Translated using Weblate (Japanese)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 13:27:29 +01:00
dc07cbda6f Translated using Weblate (German)
Currently translated at 99.6% (291 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 13:27:29 +01:00
1cf43fcd42 Translated using Weblate (German)
Currently translated at 99.6% (291 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 13:27:29 +01:00
e2cf550bed Translated using Weblate (Japanese)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 13:18:24 +01:00
2917da1138 Translated using Weblate (Korean)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-02 13:18:24 +01:00
b74eeb5688 Translated using Weblate (Japanese)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 13:16:20 +01:00
0b084a6441 Translated using Weblate (German)
Currently translated at 99.3% (290 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 13:16:20 +01:00
865efa1b12 Translated using Weblate (Japanese)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 13:13:10 +01:00
6a248e5336 Translated using Weblate (Czech)
Currently translated at 46.2% (135 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-02 13:13:10 +01:00
eb9c256a5d Translated using Weblate (German)
Currently translated at 99.3% (290 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 13:13:10 +01:00
4bd54dcb2d Translated using Weblate (Japanese)
Currently translated at 40.0% (117 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 10:58:23 +01:00
17b035d317 Translated using Weblate (Japanese)
Currently translated at 38.6% (113 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-02 10:49:31 +01:00
28bcd1fefc Translated using Weblate (Korean)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-02 02:16:18 +00:00
59bb1d9124 feat(README): add i18n README
- resolves https://github.com/th-ch/youtube-music/discussions/719
2023-12-02 10:06:55 +09:00
d9255c1cec Translated using Weblate (Korean)
Currently translated at 100.0% (292 of 292 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-02 00:27:27 +01:00
4ab4bb4cb3 feat(menu): add 'to-help-translate' button 2023-12-02 08:25:07 +09:00
a6c8b887e3 fix(in-app-menu): hide 'invisible' item from menu 2023-12-02 08:06:15 +09:00
1db0abf32d Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/
2023-12-02 00:02:39 +01:00
ff899b8720 Translated using Weblate (German)
Currently translated at 99.6% (290 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-02 00:02:39 +01:00
18004c4441 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/
2023-12-01 23:56:30 +01:00
ce1cde72bd Translated using Weblate (Norwegian Bokmål)
Currently translated at 20.2% (59 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/nb_NO/
2023-12-01 23:56:30 +01:00
453f4d92c9 Translated using Weblate (Czech)
Currently translated at 37.8% (110 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 23:56:30 +01:00
37740e78b4 Translated using Weblate (Russian)
Currently translated at 11.6% (34 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2023-12-01 23:56:30 +01:00
8ace123179 Translated using Weblate (Greek)
Currently translated at 9.6% (28 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/el/
2023-12-01 23:56:30 +01:00
bcdb9de41a chore(deps): update dependency eslint to v8.55.0 2023-12-02 07:12:19 +09:00
9fdb6eb7e5 fix(security): fix vulnerability detected by snyk 2023-12-02 07:06:39 +09:00
88cd1d2390 chore(README): replace snyk badge 2023-12-02 07:00:19 +09:00
943bcd322d Translated using Weblate (German)
Currently translated at 99.6% (290 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 22:42:08 +01:00
7774128d7e Translated using Weblate (Korean)
Currently translated at 100.0% (291 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 22:42:08 +01:00
b9b9e2ba00 Translated using Weblate (English)
Currently translated at 100.0% (291 of 291 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/en/
2023-12-01 22:42:08 +01:00
0ce4f20ec5 fix(downloader): call submenu 2023-12-02 06:39:33 +09:00
51b87312c4 feat: changed Zoom shortcuts to standard
- resolves #1458
2023-12-02 06:16:38 +09:00
9ffd7af8a7 Translated using Weblate (Japanese)
Currently translated at 36.5% (106 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 21:48:39 +01:00
4a453a4f3d Translated using Weblate (German)
Currently translated at 99.6% (289 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 21:48:39 +01:00
dc8a472cdb Translated using Weblate (Korean)
Currently translated at 100.0% (290 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 21:48:39 +01:00
d2eabaa4bb Translated using Weblate (English)
Currently translated at 100.0% (290 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/en/
2023-12-01 21:48:39 +01:00
39c8ca66d1 fix: add (Language) to Language setting 2023-12-02 05:14:22 +09:00
806098a5ef feat(tray): add separator 2023-12-02 05:11:27 +09:00
5f6cfd9558 Translated using Weblate (Japanese)
Currently translated at 34.1% (99 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 21:03:01 +01:00
b4b7ad824b Translated using Weblate (Korean)
Currently translated at 100.0% (290 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 21:03:01 +01:00
7b5d602f63 Translated using Weblate (Japanese)
Currently translated at 31.3% (91 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 21:03:01 +01:00
7eeeb89457 Translated using Weblate (Japanese)
Currently translated at 31.3% (91 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 21:03:01 +01:00
6e8447b5d1 feat: run prettier 2023-12-02 05:01:29 +09:00
a6445bacf0 Translated using Weblate (Japanese)
Currently translated at 27.9% (81 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 20:36:58 +01:00
bd9b4f1b1a Translated using Weblate (Korean)
Currently translated at 100.0% (290 of 290 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 20:36:58 +01:00
9a816b3f07 fix(song-info-front): fix eslint warning 2023-12-02 04:29:05 +09:00
4dcac23688 feat: async video event dispatch 2023-12-02 04:28:38 +09:00
97ef6ff997 fix(quality-changer): i18n 2023-12-02 04:24:02 +09:00
244a656671 Translated using Weblate (Japanese)
Currently translated at 19.4% (54 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 19:50:33 +01:00
f8a2829adb Translated using Weblate (Japanese)
Currently translated at 19.4% (54 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 19:50:33 +01:00
24daadbef8 Translated using Weblate (Czech)
Currently translated at 39.7% (110 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 19:50:33 +01:00
bf2ac88847 Translated using Weblate (German)
Currently translated at 87.7% (243 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 19:50:33 +01:00
e42423b100 Translated using Weblate (Korean)
Currently translated at 100.0% (277 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 19:50:33 +01:00
de0c02efaf Translated using Weblate (Korean)
Currently translated at 100.0% (277 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 19:50:33 +01:00
b8290417f8 fix(en): fix 'quit' string 2023-12-02 03:50:23 +09:00
b92205a228 fix: i18n for default menu 2023-12-02 03:49:25 +09:00
5f642007ba feat(executed-at-ms): displaying a float to 2 decimal places
e.g. 58.399999998509884ms -> 58.39ms
2023-12-02 03:18:30 +09:00
ee40d278d4 Translated using Weblate (Korean)
Currently translated at 100.0% (277 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 18:35:12 +01:00
02d2e8ea92 Translated using Weblate (English)
Currently translated at 100.0% (277 of 277 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/en/
2023-12-01 18:35:12 +01:00
70715e5e8a fix(discord): connected, disconnected i18n 2023-12-02 02:33:54 +09:00
5ae4f564b7 fix(i18n): plugin name, description i18n 2023-12-02 02:33:40 +09:00
123eabd77a Translated using Weblate (Czech)
Currently translated at 39.6% (109 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:14:58 +00:00
fd3438a20d feat(i18n): i18n auto-importer 2023-12-02 02:13:49 +09:00
c8554a12f6 fix(i18n): fix locale name (Norwegian (Bokmal)) 2023-12-02 01:54:32 +09:00
4a687ade9c Translated using Weblate (German)
Currently translated at 86.1% (237 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:43:01 +01:00
f77aa372cc Translated using Weblate (Japanese)
Currently translated at 5.0% (14 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 17:37:32 +01:00
0b9eef94c4 Translated using Weblate (German)
Currently translated at 85.8% (236 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:37:32 +01:00
71b2f69f98 Translated using Weblate (German)
Currently translated at 85.8% (236 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:37:31 +01:00
4b61c5307e Translated using Weblate (German)
Currently translated at 75.6% (208 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
a617b91263 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/
2023-12-01 17:06:02 +01:00
fc79bdd0f3 Translated using Weblate (Norwegian Bokmål)
Currently translated at 21.0% (58 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/nb_NO/
2023-12-01 17:06:02 +01:00
e5e1e547d5 Translated using Weblate (Japanese)
Currently translated at 4.0% (11 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 17:06:02 +01:00
edac9b0c20 Translated using Weblate (German)
Currently translated at 75.2% (207 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
dfaf3cf95a Translated using Weblate (Russian)
Currently translated at 11.2% (31 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2023-12-01 17:06:02 +01:00
bae90ce8f3 Translated using Weblate (Russian)
Currently translated at 11.2% (31 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2023-12-01 17:06:02 +01:00
188e56ce30 Translated using Weblate (Czech)
Currently translated at 39.6% (109 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:06:02 +01:00
19b48b123f Translated using Weblate (German)
Currently translated at 51.6% (142 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
549961f297 Translated using Weblate (Korean)
Currently translated at 100.0% (275 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 17:06:02 +01:00
ba7bc68ac3 Translated using Weblate (Norwegian Bokmål)
Currently translated at 10.5% (29 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/nb_NO/
2023-12-01 17:06:02 +01:00
bbfe272d41 Translated using Weblate (Japanese)
Currently translated at 2.5% (7 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ja/
2023-12-01 17:06:02 +01:00
8a3e0a31ca Translated using Weblate (Czech)
Currently translated at 39.6% (109 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:06:02 +01:00
8fbda97885 Translated using Weblate (Czech)
Currently translated at 39.6% (109 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:06:02 +01:00
1856deb0f5 Translated using Weblate (German)
Currently translated at 30.5% (84 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
fec7c5c130 Translated using Weblate (Korean)
Currently translated at 60.0% (165 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 17:06:02 +01:00
936b4b28bb Added translation using Weblate (Norwegian Bokmål) 2023-12-01 17:06:02 +01:00
f3092d0778 Added translation using Weblate (Japanese) 2023-12-01 17:06:02 +01:00
fc1adfae6c Translated using Weblate (Czech)
Currently translated at 32.7% (90 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:06:02 +01:00
e279aaed64 Translated using Weblate (Czech)
Currently translated at 100.0% (0 of 0 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2023-12-01 17:06:02 +01:00
4d346a9471 Translated using Weblate (German)
Currently translated at 29.8% (82 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
cfc504da34 Added translation using Weblate (Czech) 2023-12-01 17:06:02 +01:00
0919a4b9b7 Translated using Weblate (German)
Currently translated at 100.0% (0 of 0 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2023-12-01 17:06:02 +01:00
f46ad2ea0e Added translation using Weblate (German) 2023-12-01 17:06:02 +01:00
252719bc71 Translated using Weblate (Russian)
Currently translated at 100.0% (0 of 0 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2023-12-01 17:06:02 +01:00
45f49361ea Translated using Weblate (Greek)
Currently translated at 6.9% (19 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/el/
2023-12-01 17:06:02 +01:00
c4a74c6c7e Translated using Weblate (Korean)
Currently translated at 44.0% (121 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 17:06:02 +01:00
05f197948d Added translation using Weblate (Russian) 2023-12-01 17:06:02 +01:00
5a1d230538 Translated using Weblate (Greek)
Currently translated at 100.0% (0 of 0 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/el/
2023-12-01 17:06:02 +01:00
a7ad260a00 Translated using Weblate (Korean)
Currently translated at 18.1% (50 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 17:06:02 +01:00
ef068cccd9 Added translation using Weblate (Greek) 2023-12-01 17:06:02 +01:00
166067920d Translated using Weblate (Korean)
Currently translated at 1.0% (3 of 275 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2023-12-01 17:06:02 +01:00
8227853cf9 fix: load i18n 2023-12-01 23:50:15 +09:00
324a539b89 fix(in-app-menu): resize
- Window controls: 36px -> 32px
- Font size: 14px -> 12px
2023-12-01 23:04:49 +09:00
ce7557353c Add text to Translation section (#1470) 2023-12-01 21:41:13 +09:00
7b7923fe9b fix(downloader): fix i18n 2023-12-01 21:40:03 +09:00
105d5c78e7 chore(deps): update dependency rollup to v4.6.1 2023-12-01 21:31:13 +09:00
b25183a8f5 fix(deps): update dependency youtubei.js to v8 (#1473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 21:20:15 +09:00
adde33d1f5 chore(deps): update dependency electron to v27.1.3 (#1471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 21:18:54 +09:00
ad325ccb10 fix(deps): update dependency @xhayper/discord-rpc to v1.1.1 (#1472)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 21:17:28 +09:00
2e7ea6969c chore(README): add translation info 2023-12-01 02:24:23 +09:00
7401cf69ad feat: add support i18n (#1468) 2023-12-01 01:30:46 +09:00
7f71c36dc0 doc: Add warning in index.html 2023-11-30 11:07:32 +02:00
a3104fda4b feat: run prettier 2023-11-30 11:59:27 +09:00
44c42310f1 feat(.eslintrc.js): add prettier 2023-11-30 11:48:03 +09:00
a22a8ac5c9 feat: add description for plugins 2023-11-30 11:42:34 +09:00
aa5c3bac4e chore(deps): bump deps 2023-11-30 10:27:11 +09:00
30b3beee18 Merge pull request #1401 from organization/feat/refactor-plugin-system 2023-11-30 10:07:43 +09:00
b059e43fb1 fix(discord): rename from 'timout' to 'timeout' 2023-11-30 10:00:39 +09:00
3b04d0ba19 feat(discord): apply config as dynamic 2023-11-30 09:54:10 +09:00
959f99beae fix(blur-nav-bar): set restartNeeded to true 2023-11-30 09:41:05 +09:00
ed402933d3 fix(in-app-menu): fix #1436 2023-11-30 09:38:51 +09:00
ef8bb95884 chore(deps): update playwright monorepo to v1.40.1 2023-11-30 03:13:09 +09:00
1b79d2e429 fix(song-info-front): add type 2023-11-30 03:03:04 +09:00
ec786748be fix: dispatch event 2023-11-30 03:02:20 +09:00
06f1c7effe fix(ambient-mode): fix unload 2023-11-30 02:45:44 +09:00
d78da237fc fix: fix an issue with videodatachangefired timing 2023-11-30 00:45:15 +09:00
4c0cce89ee fix(video-toggle): remove unnecessary querySelector call 2023-11-29 19:23:58 +09:00
888ced8fd1 fix(loadAllPreloadPlugins): remove await 2023-11-29 18:27:45 +09:00
e1690720b3 fix: disable auto-play 2023-11-29 18:05:32 +09:00
bbff0a6bc2 feat: plugin load await 2023-11-29 18:04:37 +09:00
5db759150c feat: use '.call' instead of '.bind' 2023-11-29 01:27:51 +09:00
ae239f6700 fix(precise-volume): fix precise-volume plugin 2023-11-28 12:37:31 +09:00
1d26d10e57 fix: menu await 2023-11-28 12:32:07 +09:00
da70a4ce7e fix: fix audioCanPlay event
- e.g. Visualizer Plugin
2023-11-28 12:11:48 +09:00
75ae9f4fad fix: fix unloader 2023-11-28 11:48:09 +09:00
8f7933c111 fix: fix restartNeeded 2023-11-28 11:29:43 +09:00
29a0dedcce chore(action/build): fix version tag 2023-11-28 11:10:41 +09:00
4d62993177 chore(action/build): add notes for Windows
resolve https://github.com/th-ch/youtube-music/discussions/1343
2023-11-28 11:07:44 +09:00
8714f33fa2 feat: use app.whenReady() instead of app.on('ready', ...) 2023-11-28 10:55:33 +09:00
5dacd50ff6 feat: use LoggerPrefix const instead of hardcoded string 2023-11-28 10:51:19 +09:00
8d06dcc7b6 fix(utils/index): little fix (lint) 2023-11-28 10:44:48 +09:00
b8f6dd2584 chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.13.0 2023-11-28 10:41:39 +09:00
0650205b86 chore: update README 2023-11-28 10:28:39 +09:00
3e8a0ec49a fix: plugin config default value 2023-11-28 10:21:08 +09:00
04d7b32d3f feat: createBackend, createPreload, createRenderer
Introduced the mentioned helper methods in order to help split big plugins into manageable chunks.
2023-11-28 00:06:59 +02:00
eaaf170cc8 make log prefix consistent 2023-11-27 23:31:05 +02:00
09450fb8c7 fix(store): fix TypeError: Cannot convert undefined or null to object 2023-11-28 05:51:47 +09:00
ac0b78eefb fix(store): fix Cannot read properties of undefined 'global' 2023-11-28 05:46:49 +09:00
90103d9853 fix: lifecycle check
edge case:
 - There may be plugins that don't have a start or stop function
2023-11-28 05:23:35 +09:00
bf27c73f1d fix(album-color-theme): more simple assign 2023-11-28 04:50:24 +09:00
845c9365be fix(album-color-theme): sidebar color 2023-11-28 04:44:17 +09:00
91cf5f5c25 fix: fix Cannot access 'Ba' before initialization on first run 2023-11-28 04:21:58 +09:00
783a892e26 bump rollup version 2023-11-28 01:59:15 +09:00
41d8f86962 Merge branch 'master' into feat/refactor-plugin-system 2023-11-28 01:51:31 +09:00
252349579e chore(deps): update dependency rollup to v4.6.0 2023-11-28 01:51:10 +09:00
99b1cfbde4 fix: fix pnpm-lock 2023-11-28 01:50:46 +09:00
3f70d912d7 Merge branch 'master' into feat/refactor-plugin-system 2023-11-28 01:48:38 +09:00
bf33c4e7b4 Merge branch 'feat/new-plugin-system' into feat/refactor-plugin-system 2023-11-28 01:43:51 +09:00
3152842a30 fix: this binding
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-28 01:39:11 +09:00
d84416b27c fix: crash 2023-11-28 01:02:05 +09:00
cc38978bd3 feat: label sort 2023-11-28 00:51:16 +09:00
7a76079ff4 fix: load plugins
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-28 00:34:36 +09:00
2fe28cf126 clean code 2023-11-27 19:26:45 +09:00
3ffbfbe0e3 convert plugins 2023-11-27 18:41:50 +09:00
4fad456619 fix eslint warning 2023-11-27 05:10:35 +09:00
7591f13505 in-app-menu 2023-11-27 05:09:33 +09:00
11d06c50a5 WIP 2 2023-11-27 04:59:20 +09:00
e0a3489640 fix: remove PluginBaseConfig 2023-11-27 00:57:05 +09:00
e55a1d3076 fix: fix onConfigChange 2023-11-27 00:44:46 +09:00
563d431c00 WIP
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-27 00:18:34 +09:00
3a1b77ebd8 fix: electron-vite setting
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-26 23:25:57 +09:00
3f8030a9c5 fix: inject multiple stylesheet 2023-11-26 23:25:38 +09:00
e12e67af0e feat: reimplement inject css, fix types 2023-11-26 23:12:19 +09:00
3ab4cd5d05 change plugin system 2023-11-26 01:17:24 +02:00
738adbed98 chore(deps): update dependency electron-builder to v24.9.1 2023-11-25 20:44:32 +09:00
365a078600 fix(deps): update dependency electron-updater to v6.1.7 2023-11-25 20:41:44 +09:00
04fc43e18b chore(deps): update pnpm to v8.11.0 2023-11-25 20:41:33 +09:00
54273baec7 chore(deps): update dependency rollup to v4.5.2 2023-11-24 20:56:29 +09:00
51e62ef47b fix(discord): update application client-id
fix #1431
2023-11-23 12:12:08 +09:00
a330ebcda7 chore(deps): update dependency electron to v27.1.2 (#1441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-23 09:21:08 +09:00
a023fff2d0 Nicer Readme (#1439) 2023-11-22 15:33:49 +09:00
abb25ea6fb chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.12.0 2023-11-22 15:29:51 +09:00
ef49bcdb5f chore(deps): update dependency typescript to v5.3.2 2023-11-22 15:25:37 +09:00
b4f1b112d6 chore(deps): update dependency rollup to v4.5.1 2023-11-22 15:23:11 +09:00
f24ec0ae9d chore(deps): update dependency rollup to v4.5.0 2023-11-20 15:44:36 +09:00
ebb51fe37b chore(deps): update dependency eslint to v8.54.0 2023-11-20 15:44:28 +09:00
e8ee18f903 chore(deps): update dependency electron-vite to v1.0.29 2023-11-20 15:42:36 +09:00
a593de705c chore(deps): update playwright monorepo to v1.40.0 2023-11-20 15:42:25 +09:00
03dd024704 Windows Zoom, ScaleFactor (#1402) 2023-11-20 15:42:04 +09:00
528c3535dd fix(deps): update dependency @xhayper/discord-rpc to v1.1.0 2023-11-19 21:01:47 +09:00
0e0f80a2d0 chore(deps): update dependency electron to v27.1.0 2023-11-16 09:26:50 +09:00
6b67fb136a fix(deps): update dependency conf to v12 2023-11-15 21:52:58 +09:00
9fe1c14869 chore(deps): update dependency @electron/universal to v2 2023-11-15 18:09:00 +09:00
8a96dddf54 pnpm dedupe 2023-11-15 18:08:25 +09:00
230422c98b pnpm dedupe 2023-11-15 18:02:51 +09:00
d16ffc531f fix: renovate.json 2023-11-15 18:02:05 +09:00
f614199ea5 feat: remove support for npm
- remove yarpm
2023-11-15 17:56:52 +09:00
55a1c2e9e3 fix(package.json): fix spaces in overrides 2023-11-15 17:53:32 +09:00
bee1f77812 chore(deps): bump rollup & pnpm 2023-11-15 17:51:53 +09:00
fdf982ada5 chore(deps): bump deps 2023-11-15 17:50:07 +09:00
ff02fc7855 chore(deps): bump axios from 1.5.1 to 1.6.1 (#1400)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-15 17:43:16 +09:00
01ed289400 feat(renovate): add labels for dependency update 2023-11-13 01:40:33 +09:00
TC
aedb2db655 Add renovate config file 2023-11-12 17:35:02 +01:00
10a54b9de0 feat: update README 2023-11-12 02:56:02 +09:00
ccd029c040 fix: update types 2023-11-12 02:55:44 +09:00
3a431841b7 fix(plugin): fix always show restart dialog 2023-11-12 02:10:50 +09:00
deceae8354 chore(plugin): clean import 2023-11-12 02:10:11 +09:00
c8628670cf fix(ambient-plugin): fix plugin definition 2023-11-12 02:05:12 +09:00
ffe53d5596 feat(plugin): show dialog need to restart 2023-11-12 02:02:54 +09:00
a4f4ecb569 feat(plugin): add onPlayerApiReady hook
Co-authored-by: JellyBrick <shlee1503@naver.com>
2023-11-12 01:51:26 +09:00
2097f42efb feat(plugin): support dynamic plugin load / unload 2023-11-12 01:16:34 +09:00
9c59f56aac refactor(plugin): apply new plugin loader at all type of plugin 2023-11-12 00:50:09 +09:00
dfcc4107b7 feat(menu): sort plugin name 2023-11-12 00:31:48 +09:00
ef71abfff1 refactor(plugin): add renderer-plugin-loader 2023-11-12 00:21:34 +09:00
bc916f3a6e refactor(plugin): refactor plugin loader and add dynamic loading 2023-11-12 00:09:56 +09:00
c7ff0dcbf6 fix: remove defaults
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-11 22:43:51 +09:00
7242f9bfd0 fix(plugin): fix onChangeConfig hook 2023-11-11 22:08:44 +09:00
bb2e865880 wip: trying to fix electron-store issue
Co-authored-by: JellyBrick <shlee1503@naver.com>
2023-11-11 21:48:51 +09:00
6ab3cf9ac9 fix(frontend): fix cannot apply style 2023-11-11 20:58:16 +09:00
b77f5c9ecc fix: fix insert 2023-11-11 20:05:38 +09:00
b470dbd6b9 fix: add default config 2023-11-11 19:32:05 +09:00
1f96b6b44d fix: plugin load 2023-11-11 19:23:17 +09:00
de0b228ae8 feat: electron-vite hot-reload 2023-11-11 18:12:20 +09:00
f35d192650 fix(downloader): fix downloader plugin 2023-11-11 18:11:24 +09:00
794d00ce9e feat: migrate to new plugin api
Co-authored-by: Su-Yong <simssy2205@gmail.com>
2023-11-11 18:02:22 +09:00
739e7a448b remove unused Promise.resolve 2023-11-11 11:51:06 +09:00
7fa8a454b6 fix: fix setPartial 2023-11-11 11:50:02 +09:00
5cd1d9abe8 feat(plugin): migrate some plugin (WIP)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2023-11-11 00:03:26 +09:00
e0e17cac99 feat(plugin): migrating plugins to new plugin system (WIP)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2023-11-10 21:32:05 +09:00
840039330f fix: Update bug_report.yml 2023-11-10 20:09:23 +09:00
734409dc3f chore: Update bug_report.yml 2023-11-10 20:09:04 +09:00
34564c8c55 Updated mac icon to better reflect the Mac styling (#1395) 2023-11-10 12:33:17 +09:00
afe6accab8 refactor(plugin): new plugin system poc 2023-11-09 22:54:58 +09:00
b6e7e75ae8 plugin system poc 2023-11-09 12:43:41 +09:00
06dc0e80f0 feat: rename plugins to clarify context (#1392) 2023-11-09 11:35:43 +09:00
47cccbce7c feat: refactor plugin utils (#1391) 2023-11-09 11:06:06 +09:00
269352af97 chore(deps): update dependency electron to v27.0.4
- fix #1324
2023-11-09 10:39:43 +09:00
fa62f79dce chore(deps): bump deps 2023-11-09 10:31:30 +09:00
9f88b37f41 fix(in-app-menu): panel should close with the window when it is closed
- fix #1389
2023-11-09 10:27:07 +09:00
55ae9eac1e fix: fixed issues identified in eslint 2023-11-09 10:06:03 +09:00
05564d4a58 feat: add auto-importer for menu 2023-11-09 09:57:32 +09:00
59426c56db feat: plugin auto-importer with vite-plugin-resolve (#1385) 2023-11-09 09:22:23 +09:00
18cd4c0c9a chore(navigation): remove unused css attribute 2023-11-08 18:15:24 +09:00
a0e2a33e28 fix: change titleBarOverlay height based on zoomFactor
- fix #1375
2023-11-08 17:48:32 +09:00
7bdb46e161 fix: fixed an issue if "Always on top" is enabled, the dialog is displayed below the window
- fix #1379
2023-11-08 16:35:19 +09:00
f560b62de0 fix(precise-volume): fix precise-volume plugin 2023-11-08 02:59:20 +09:00
adc1f6822b fix: image path 2023-11-07 23:43:16 +09:00
2da29fcfa7 feat: migrate from rollup to electron-vite (#1364)
* feat: electron-vite PoC

* fix: fix preload path

* remove rollup deps and config

* fix: debug mode

* fix: build mode, asset path

* fix: remove unused dependencies

* feat: use `executeJavaScriptInIsolatedWorld` instead of `executeJavaScript`

* feat: enable `minify`

* fix(actions): update task name

* fix: fix dev mode check

* fix: remove unused variable
2023-11-07 19:49:28 +09:00
c5d0314db6 fix(winget): fix env name 2023-11-07 08:27:20 +09:00
8c052faedd fix: fix winget version (fix #1363) 2023-11-06 17:38:57 +09:00
37067ff950 fix: update jsdoc 2023-11-06 17:22:46 +09:00
6366dc026e feat: enable context-isolation (#1361) 2023-11-06 17:21:29 +09:00
6e52178074 fix: fix README 2023-11-06 17:13:47 +09:00
47f38cc690 fix: add workaround for podcast type video (#1362) 2023-11-04 16:56:41 +09:00
fdd6d9929f fix: temporary workaround for #1356 2023-11-04 10:58:32 +09:00
1707261f49 chore(deps): bump deps 2023-11-04 10:58:14 +09:00
6712fced6d fix: fix broken menu-layout (#1360) 2023-11-04 10:46:49 +09:00
6dabfaa9ba fix: use window instead of (global as any) 2023-11-04 10:30:39 +09:00
235 changed files with 16468 additions and 7575 deletions

View File

@ -1,3 +1 @@
.eslintrc.js .eslintrc.js
rollup.main.config.ts
rollup.preload.config.ts

View File

@ -7,7 +7,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking', 'plugin:@typescript-eslint/recommended-requiring-type-checking',
], ],
plugins: ['@typescript-eslint', 'import'], plugins: ['prettier', '@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
project: './tsconfig.json', project: './tsconfig.json',
@ -26,6 +26,7 @@ module.exports = {
'import/newline-after-import': 'error', 'import/newline-after-import': 'error',
'import/no-default-export': 'off', 'import/no-default-export': 'off',
'import/no-duplicates': 'error', 'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
'import/order': [ 'import/order': [
'error', 'error',
{ {
@ -66,4 +67,14 @@ module.exports = {
es6: true, es6: true,
}, },
ignorePatterns: ['dist', 'node_modules'], ignorePatterns: ['dist', 'node_modules'],
root: true,
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
typescript: {},
exports: {},
},
},
}; };

View File

@ -57,10 +57,17 @@ body:
label: Last Known Working YouTube Music (Application) version label: Last Known Working YouTube Music (Application) version
description: (If applicable) What is the last version of YouTube Music this worked in? description: (If applicable) What is the last version of YouTube Music this worked in?
placeholder: 1.20.0 placeholder: 1.20.0
- type: textarea
attributes:
label: Reproduction steps
description: Provide steps to reproduce the issue.
placeholder: 1. Enable the X plugin.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Expected Behavior label: Expected Behavior
description: A clear and concise description of what you expected to happen. (Add a replication step if applicable) description: A clear and concise description of what you expected to happen.
validations: validations:
required: true required: true
- type: textarea - type: textarea
@ -69,6 +76,13 @@ body:
description: A clear description of what actually happens. description: A clear description of what actually happens.
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Enabled plugins
description: Provide the list of plugins you enabled.
placeholder: 1. Album Color Theme
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Additional Information label: Additional Information

View File

@ -42,8 +42,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Only rollup build without release if it is a fork # Only vite build without release if it is a fork, or it is a pull-request
- name: Rollup Build - name: Vite Build
if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request' if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request'
run: | run: |
pnpm build pnpm build
@ -146,6 +146,8 @@ jobs:
Thanks to all contributors! 🏅 Thanks to all contributors! 🏅
(Note for Windows: `YouTube-Music-Web-Setup-${{ env.VERSION_TAG }}.exe` is an installer, and `YouTube-Music-${{ env.VERSION_TAG }}.exe` is a portable version)
- name: Update changelog - name: Update changelog
if: ${{ env.VERSION_HASH == '' }} if: ${{ env.VERSION_HASH == '' }}
run: | run: |

View File

@ -15,12 +15,16 @@ jobs:
name: Publish winget package name: Publish winget package
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set winget version env
env:
TAG_NAME: ${{ inputs.tag_name || github.event.release.tag_name }}
run: echo "WINGET_TAG_NAME=$(echo ${TAG_NAME#v})" >> $GITHUB_ENV
- name: Submit package to Windows Package Manager Community Repository - name: Submit package to Windows Package Manager Community Repository
uses: vedantmgoyal2009/winget-releaser@v2 uses: vedantmgoyal2009/winget-releaser@v2
with: with:
identifier: th-ch.YouTubeMusic identifier: th-ch.YouTubeMusic
installers-regex: '^YouTube-Music-Web-Setup-[\d\.]+\.exe$' installers-regex: '^YouTube-Music-Web-Setup-[\d\.]+\.exe$'
version: ${{ inputs.tag_name || github.event.release.tag_name }} version: ${{ env.WINGET_TAG_NAME }}
release-tag: ${{ inputs.tag_name || github.event.release.tag_name }} release-tag: ${{ inputs.tag_name || github.event.release.tag_name }}
token: ${{ secrets.WINGET_ACC_TOKEN }} token: ${{ secrets.WINGET_ACC_TOKEN }}
fork-user: youtube-music-winget fork-user: youtube-music-winget

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ electron-builder.yml
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.vite-inspect

View File

@ -6,9 +6,9 @@
[![GitHub license](https://img.shields.io/github/license/th-ch/youtube-music.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/LICENSE) [![GitHub license](https://img.shields.io/github/license/th-ch/youtube-music.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
[![eslint code style](https://img.shields.io/badge/code_style-eslint-5ed9c7.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js) [![eslint code style](https://img.shields.io/badge/code_style-eslint-5ed9c7.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
[![Build status](https://img.shields.io/github/actions/workflow/status/th-ch/youtube-music/build.yml?branch=master&style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/) [![Build status](https://img.shields.io/github/actions/workflow/status/th-ch/youtube-music/build.yml?branch=master&style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![Known Vulnerabilities](https://img.shields.io/snyk/vulnerabilities/github/th-ch/youtube-music?style=for-the-badge)](https://snyk.io/test/github/th-ch/youtube-music)
[![GitHub All Releases](https://img.shields.io/github/downloads/th-ch/youtube-music/total?style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/) [![GitHub All Releases](https://img.shields.io/github/downloads/th-ch/youtube-music/total?style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![AUR](https://img.shields.io/aur/version/youtube-music-bin?color=blueviolet&style=for-the-badge&logo=youtube-music)](https://aur.archlinux.org/packages/youtube-music-bin) [![AUR](https://img.shields.io/aur/version/youtube-music-bin?color=blueviolet&style=for-the-badge&logo=youtube-music)](https://aur.archlinux.org/packages/youtube-music-bin)
[![Known Vulnerabilities](https://snyk.io/test/github/th-ch/youtube-music/badge.svg)](https://snyk.io/test/github/th-ch/youtube-music)
</div> </div>
@ -20,12 +20,23 @@
</a> </a>
</div> </div>
Read this in other languages: [🇰🇷](./docs/readme/README-ko.md)
**Electron wrapper around YouTube Music featuring:** **Electron wrapper around YouTube Music featuring:**
- Native look & feel, aims at keeping the original interface - Native look & feel, aims at keeping the original interface
- Framework for custom plugins: change YouTube Music to your needs (style, content, features), enable/disable plugins in - Framework for custom plugins: change YouTube Music to your needs (style, content, features), enable/disable plugins in
one click one click
## Translation
You can help with translation on [Hosted Weblate](https://hosted.weblate.org/projects/youtube-music/).
<a href="https://hosted.weblate.org/engage/youtube-music/">
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/multi-auto.svg" alt="translation status" />
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/287x66-black.png" alt="translation status 2" />
</a>
## Download ## Download
You can check out the [latest release](https://github.com/th-ch/youtube-music/releases/latest) to quickly find the You can check out the [latest release](https://github.com/th-ch/youtube-music/releases/latest) to quickly find the
@ -80,6 +91,14 @@ winget install th-ch.YouTubeMusic
- Place them in the **same directory**. - Place them in the **same directory**.
- Run the installer. - Run the installer.
## Features:
- **Auto confirm when paused** (Always Enabled): disable
the ["Continue Watching?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png)
popup that pause music after a certain time
- And more ...
## Available plugins: ## Available plugins:
- **Ad Blocker**: Block all ads and tracking out of the box - **Ad Blocker**: Block all ads and tracking out of the box
@ -169,15 +188,6 @@ winget install th-ch.YouTubeMusic
- **Visualizer**: Different music visualizers - **Visualizer**: Different music visualizers
---
- **Auto confirm when paused** (Always Enabled): disable
the ["Continue Watching?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png)
popup that pause music after a certain time
> If `Hide Menu` option is on - you can show the menu with the <kbd>alt</kbd> key (or <kbd>\`</kbd> [backtick] if using
> the in-app-menu plugin)
## Themes ## Themes
You can load CSS files to change the look of the application (Options > Visual Tweaks > Themes). You can load CSS files to change the look of the application (Options > Visual Tweaks > Themes).
@ -190,7 +200,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
git clone https://github.com/th-ch/youtube-music git clone https://github.com/th-ch/youtube-music
cd youtube-music cd youtube-music
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm start pnpm dev
``` ```
## Build your own plugins ## Build your own plugins
@ -204,47 +214,70 @@ Using plugins, you can:
Create a folder in `plugins/YOUR-PLUGIN-NAME`: Create a folder in `plugins/YOUR-PLUGIN-NAME`:
- if you need to manipulate the BrowserWindow, create a file with the following template: - `index.ts`: the main file of the plugin
```typescript ```typescript
// file: back.ts import style from './style.css?inline'; // import style as inline
export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
// something
};
```
then, register the plugin in `index.ts`: import { createPlugin } from '@/utils';
```typescript export default createPlugin({
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back'; name: 'Plugin Label',
restartNeeded: true, // if value is true, ytmusic show restart dialog
config: {
enabled: false,
}, // your custom config
stylesheets: [style], // your custom style,
menu: async ({ getConfig, setConfig }) => {
// All *Config methods are wrapped Promise<T>
const config = await getConfig();
return [
{
label: 'menu',
submenu: [1, 2, 3].map((value) => ({
label: `value ${value}`,
type: 'radio',
checked: config.value === value,
click() {
setConfig({ value });
},
})),
},
];
},
backend: {
start({ window, ipc }) {
window.maximize();
// ... // you can communicate with renderer plugin
ipc.handle('some-event', () => {
const mainPlugins = { return 'hello';
// ... });
'YOUR-PLUGIN-NAME': yourPlugin, },
}; // it fired when config changed
``` onConfigChange(newConfig) { /* ... */ },
// it fired when plugin disabled
- if you need to change the front, create a file with the following template: stop(context) { /* ... */ },
},
```typescript renderer: {
// file: front.ts async start(context) {
export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => { console.log(await context.ipc.invoke('some-event'));
// This function will be called as a preload script },
// So you can use front features like `document.querySelector` // Only renderer available hook
}; onPlayerApiReady(api: YoutubePlayer, context: RendererContext) {
``` // set plugin config easily
context.setConfig({ myConfig: api.getVolume() });
then, register the plugin in `preload.ts`: },
onConfigChange(newConfig) { /* ... */ },
```typescript stop(_context) { /* ... */ },
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front'; },
preload: {
const rendererPlugins: PluginMapper<'renderer'> = { async start({ getConfig }) {
// ... const config = await getConfig();
'YOUR-PLUGIN-NAME': yourPlugin, },
}; onConfigChange(newConfig) {},
stop(_context) {},
},
});
``` ```
### Common use cases ### Common use cases
@ -252,27 +285,42 @@ const rendererPlugins: PluginMapper<'renderer'> = {
- injecting custom CSS: create a `style.css` file in the same folder then: - injecting custom CSS: create a `style.css` file in the same folder then:
```typescript ```typescript
import path from 'node:path'; // index.ts
import { injectCSS } from '../utils'; import style from './style.css?inline'; // import style as inline
// back.ts import { createPlugin } from '@/utils';
export default (win: Electron.BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css')); const builder = createPlugin({
}; name: 'Plugin Label',
restartNeeded: true, // if value is true, ytmusic show restart dialog
config: {
enabled: false,
}, // your custom config
stylesheets: [style], // your custom style
renderer() {} // define renderer hook
});
``` ```
- changing the HTML: - If you want to change the HTML:
```typescript ```typescript
// front.ts import { createPlugin } from '@/utils';
export default () => {
// Remove the login button const builder = createPlugin({
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove(); name: 'Plugin Label',
}; restartNeeded: true, // if value is true, ytmusic show restart dialog
config: {
enabled: false,
}, // your custom config
renderer() {
// Remove the login button
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
} // define renderer hook
});
``` ```
- communicating between the front and back: can be done using the ipcMain module from electron. See `utils.js` file and - communicating between the front and back: can be done using the ipcMain module from electron. See `index.ts` file and
example in `navigation` plugin. example in `sponsorblock` plugin.
## Build ## Build
@ -288,6 +336,12 @@ export default () => {
Builds the app for macOS, Linux, and Windows, Builds the app for macOS, Linux, and Windows,
using [electron-builder](https://github.com/electron-userland/electron-builder). using [electron-builder](https://github.com/electron-userland/electron-builder).
## Production Preview
```bash
pnpm start
```
## Tests ## Tests
```bash ```bash
@ -299,3 +353,10 @@ Uses [Playwright](https://playwright.dev/) to test the app.
## License ## License
MIT © [th-ch](https://github.com/th-ch/youtube-music) MIT © [th-ch](https://github.com/th-ch/youtube-music)
## Most asked questions
### Why apps menu isn't showing up?
If `Hide Menu` option is on - you can show the menu with the <kbd>alt</kbd> key (or <kbd>\`</kbd> [backtick] if using
the in-app-menu plugin)

Binary file not shown.

View File

@ -2,8 +2,51 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v3.0.1](https://github.com/th-ch/youtube-music/compare/v3.0.0...v3.0.1)
- hotfix(adblocker): fix #1475 [`#1475`](https://github.com/th-ch/youtube-music/issues/1475)
- Translated using Weblate (French) [`7f02afc`](https://github.com/th-ch/youtube-music/commit/7f02afc5a6839adfe8437d4e2cc8dee13a93b311)
- Update changelog for v3.0.0 [`d8c8bd1`](https://github.com/th-ch/youtube-music/commit/d8c8bd17ecfbdf96ebd29eb4c5748c07876ee242)
- Translated using Weblate (German) [`0660f0b`](https://github.com/th-ch/youtube-music/commit/0660f0b7ce6895ef5800f48ade1da2d7f8e0c1f7)
### [v3.0.0](https://github.com/th-ch/youtube-music/compare/v2.2.0...v3.0.0)
> 2 December 2023
- Add text to Translation section [`#1470`](https://github.com/th-ch/youtube-music/pull/1470)
- fix(deps): update dependency youtubei.js to v8 [`#1473`](https://github.com/th-ch/youtube-music/pull/1473)
- chore(deps): update dependency electron to v27.1.3 [`#1471`](https://github.com/th-ch/youtube-music/pull/1471)
- fix(deps): update dependency @xhayper/discord-rpc to v1.1.1 [`#1472`](https://github.com/th-ch/youtube-music/pull/1472)
- feat: add support i18n [`#1468`](https://github.com/th-ch/youtube-music/pull/1468)
- chore(deps): update dependency electron to v27.1.2 [`#1441`](https://github.com/th-ch/youtube-music/pull/1441)
- Nicer Readme [`#1439`](https://github.com/th-ch/youtube-music/pull/1439)
- Windows Zoom, ScaleFactor [`#1402`](https://github.com/th-ch/youtube-music/pull/1402)
- chore(deps): bump axios from 1.5.1 to 1.6.1 [`#1400`](https://github.com/th-ch/youtube-music/pull/1400)
- Updated mac icon to better reflect the Mac styling [`#1395`](https://github.com/th-ch/youtube-music/pull/1395)
- feat: rename plugins to clarify context [`#1392`](https://github.com/th-ch/youtube-music/pull/1392)
- feat: refactor plugin utils [`#1391`](https://github.com/th-ch/youtube-music/pull/1391)
- feat: plugin auto-importer with `vite-plugin-resolve` [`#1385`](https://github.com/th-ch/youtube-music/pull/1385)
- feat: migrate from `rollup` to `electron-vite` [`#1364`](https://github.com/th-ch/youtube-music/pull/1364)
- feat: enable `context-isolation` [`#1361`](https://github.com/th-ch/youtube-music/pull/1361)
- fix: add workaround for `podcast` type video [`#1362`](https://github.com/th-ch/youtube-music/pull/1362)
- fix: fix broken menu-layout [`#1360`](https://github.com/th-ch/youtube-music/pull/1360)
- Add Homebrew cask install option for MacOS. [`#1357`](https://github.com/th-ch/youtube-music/pull/1357)
- feat: changed Zoom shortcuts to standard [`#1458`](https://github.com/th-ch/youtube-music/issues/1458)
- fix(in-app-menu): fix #1436 [`#1436`](https://github.com/th-ch/youtube-music/issues/1436)
- fix(discord): update application client-id [`#1431`](https://github.com/th-ch/youtube-music/issues/1431)
- chore(deps): update dependency electron to v27.0.4 [`#1324`](https://github.com/th-ch/youtube-music/issues/1324)
- fix(in-app-menu): panel should close with the window when it is closed [`#1389`](https://github.com/th-ch/youtube-music/issues/1389)
- fix: change titleBarOverlay height based on zoomFactor [`#1375`](https://github.com/th-ch/youtube-music/issues/1375)
- fix: fixed an issue if "Always on top" is enabled, the dialog is displayed below the window [`#1379`](https://github.com/th-ch/youtube-music/issues/1379)
- fix: fix winget version (fix #1363) [`#1363`](https://github.com/th-ch/youtube-music/issues/1363)
- feat: run prettier [`a3104fd`](https://github.com/th-ch/youtube-music/commit/a3104fda4b0d58b076d0c737111636a66e468acc)
- Translated using Weblate (Korean) [`b4b7ad8`](https://github.com/th-ch/youtube-music/commit/b4b7ad824b8c489ae483eba139b46e5b200231fc)
- Translated using Weblate (English) [`d2eabaa`](https://github.com/th-ch/youtube-music/commit/d2eabaa4bbccd89eae529eae52cec035e8e2620c)
#### [v2.2.0](https://github.com/th-ch/youtube-music/compare/v2.1.3...v2.2.0) #### [v2.2.0](https://github.com/th-ch/youtube-music/compare/v2.1.3...v2.2.0)
> 27 October 2023
- feat(ambient-mode): add config for `ambient-mode` plugin [`#1349`](https://github.com/th-ch/youtube-music/pull/1349) - feat(ambient-mode): add config for `ambient-mode` plugin [`#1349`](https://github.com/th-ch/youtube-music/pull/1349)
- bump deps [`4248d20`](https://github.com/th-ch/youtube-music/commit/4248d20e8ef926ce7b1d07eb83743755a341d9f6) - bump deps [`4248d20`](https://github.com/th-ch/youtube-music/commit/4248d20e8ef926ce7b1d07eb83743755a341d9f6)
- Update changelog for v2.1.3 [`dc73561`](https://github.com/th-ch/youtube-music/commit/dc73561c8a8acfc8ba91aff2dc78e4267869f2fd) - Update changelog for v2.1.3 [`dc73561`](https://github.com/th-ch/youtube-music/commit/dc73561c8a8acfc8ba91aff2dc78e4267869f2fd)

327
docs/readme/README-ko.md Normal file
View File

@ -0,0 +1,327 @@
# 유튜브 뮤직 (YouTube Music)
<div align="center">
[![GitHub release](https://img.shields.io/github/release/th-ch/youtube-music.svg?style=for-the-badge&logo=youtube-music)](https://github.com/th-ch/youtube-music/releases/)
[![GitHub license](https://img.shields.io/github/license/th-ch/youtube-music.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
[![eslint code style](https://img.shields.io/badge/code_style-eslint-5ed9c7.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
[![Build status](https://img.shields.io/github/actions/workflow/status/th-ch/youtube-music/build.yml?branch=master&style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![GitHub All Releases](https://img.shields.io/github/downloads/th-ch/youtube-music/total?style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![AUR](https://img.shields.io/aur/version/youtube-music-bin?color=blueviolet&style=for-the-badge&logo=youtube-music)](https://aur.archlinux.org/packages/youtube-music-bin)
[![Known Vulnerabilities](https://snyk.io/test/github/th-ch/youtube-music/badge.svg)](https://snyk.io/test/github/th-ch/youtube-music)
</div>
![Screenshot](../../web/screenshot.jpg "Screenshot")
<div align="center">
<a href="https://github.com/th-ch/youtube-music/releases/latest">
<img src="../../web/youtube-music.svg" width="400" height="100" alt="YouTube Music SVG">
</a>
</div>
**유튜브 뮤직의 Electron 래퍼; 기능:**
- 원래의 인터페이스를 유지하는 것을 목표로 하는 네이티브 디자인 및 느낌
- 맞춤 플러그인을 위한 프레임워크: 스타일, 콘텐츠, 기능 등 필요에 따라 유튜브 뮤직을 변경하고, 클릭 한 번으로 플러그인을 활성화/비활성화할 수 있습니다.
## 번역
[Hosted Weblate](https://hosted.weblate.org/projects/youtube-music/)에서 번역을 도울 수 있습니다.
<a href="https://hosted.weblate.org/engage/youtube-music/">
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/multi-auto.svg" alt="번역 상태" />
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/287x66-black.png" alt="번역 상태 2" />
</a>
## 다운로드
[최신 릴리즈](https://github.com/th-ch/youtube-music/releases/latest)를 확인하여 최신 버전을 빠르게 찾을 수 있습니다.
### Arch Linux
AUR에서 `youtube-music-bin` 패키지를 설치합니다. AUR 설치 지침은 [이 위키 페이지](https://wiki.archlinux.org/index.php/Arch_User_Repository#Installing_packages)를 참조하세요.
### MacOS
Homebrew를 사용하여 앱을 설치할 수 있습니다:
```bash
brew install --cask https://raw.githubusercontent.com/th-ch/youtube-music/master/youtube-music.rb
```
(앱을 수동으로 설치하고) 앱을 실행할 때 `손상되었기 때문에 열 수 없습니다.`라는 오류가 발생하면 터미널에서 다음을 실행하세요:
```bash
xattr -cr /Applications/YouTube\ Music.app
```
### Windows
[Scoop 패키지 매니저](https://scoop.sh)를 사용하여 [`extras` 버킷](https://github.com/ScoopInstaller/Extras)에서 `youtube-music` 패키지를 설치할 수 있습니다.
```bash
scoop bucket add extras
scoop install extras/youtube-music
```
또는 Windows 11의 공식 CLI 패키지 관리자인 [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/)을 사용하여 `th-ch.YouTubeMusic` 패키지를 설치할 수 있습니다.
*참고: "알 수 없는 게시자"의 파일이기 때문에 Microsoft Defender의 SmartScreen에서 설치를 차단할 수 있습니다. 이는 GitHub에서 동일 파일을 수동으로 다운로드한 후 실행 파일(.exe)을 실행하려고 할 때도 마찬가지로 발생합니다.*
```bash
winget install th-ch.YouTubeMusic
```
#### (Windows에서) 네트워크에 연결하지 않고 설치하는 방법은 무엇인가요?
- [릴리즈 페이지](https://github.com/th-ch/youtube-music/releases/latest)에서 _본인 기기 아키텍처_에 맞는 `*.nsis.7z` 파일을 다운로드하세요.
- `x64`는 64비트 Windows 용입니다.
- `ia32`는 32비트 Windows 용입니다.
- `arm64`는 ARM64 Windows 용입니다.
- 릴리즈 페이지에서 설치기를 다운로드하세요. (`*-Setup.exe`)
- 두 파일을 **동일한 위치**에 놓아주세요.
- 설치기를 실행하세요.
## 기능:
- **일시 정지 시 자동 확인** (항상 활성화 됨): 일정 시간이 지나면 음악을 일시 정지하는 ["계속 시청하시겠습니까?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png) 팝업을 비활성화합니다.
- 이외에 더 많은 기능 ...
## 사용 가능한 플러그인:
- **애드블록**: 모든 광고와 트래커를 즉시 차단합니다
- **앨범 컬러 기반 테마**: 앨범 색상 팔레트를 기반으로 동적 테마 및 시각 효과를 적용합니다
- **앰비언트 모드**: 영상의 간접 조명을 화면 배경에 투사합니다.
- **오디오 컴프레서**: 오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)
- **네비게이션 바 흐림 효과**: 내비게이션 바를 투명하고 흐릿하게 만듭니다
- **나이 제한 우회**: 유튜브의 나이 제한을 우회합니다
- **자막 선택기**: 자막을 활성화합니다
- **컴팩트 사이드바**: 사이드바를 항상 컴팩트 모드로 설정합니다
- **크로스페이드**: 노래 사이에 크로스페이드 효과를 적용합니다
- **자동 재생 해제**: 노래를 '일시 정지' 모드로 시작하게 합니다
- [**디스코드 활동 상태**](https://discord.com/): [활동 상태 (Rich Presence)](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)를 사용하여 친구들에게 내가 듣는 음악을 보여주세요
- **다운로더**: UI에서 [직접](https://user-images.githubusercontent.com/61631665/129977677-83a7d067-c192-45e1-98ae-b5a4927393be.png) MP3/소스 오디오를 다운로드하세요
- **지수 볼륨**: 음량 슬라이더를 [지수적](https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/)으로 만들어 더 낮은 음량을 쉽게 선택할 수 있도록 합니다.
- **인앱 메뉴**: [메뉴 표시줄을 더 멋지게, 그리고 다크 또는 앨범의 색상으로 만듭니다](https://user-images.githubusercontent.com/78568641/112215894-923dbf00-8c29-11eb-95c3-3ce15db27eca.png)
> (이 플러그인 및 메뉴 숨기기 옵션을 활성화한 후 메뉴에 액세스하는 데 문제가 있는 경우 [이 글](https://github.com/th-ch/youtube-music/issues/410#issuecomment-952060709)을 참조하세요)
- [**Last.fm**](https://www.last.fm/): Last.fm에 대한 스크러블 지원을 추가합니다
- **Lumia Stream**: [Lumia Stream](https://lumiastream.com/) 지원을 추가합니다
- **Genius 가사**: 더 많은 곡에 대해 가사 지원을 추가합니다
- **네비게이션**: 브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표를 추가합니다
- **Google 로그인 제거**: UI에서 Google 로그인 버튼 및 링크 제거하기
- **알림**: 노래 재생이 시작되면 알림을 표시 (Windows에서는 [대화형 알림](https://user-images.githubusercontent.com/78568641/114102651-63ce0e00-98d0-11eb-9dfe-c5a02bb54f9c.png) 사용 가능)
- **PiP**: 앱을 PiP 모드로 전환할 수 있게 허용합니다
- **재생 속도**: 빨리 듣거나, 천천히 들어보세요! [노래 속도를 제어하는 슬라이더를 추가합니다](https://user-images.githubusercontent.com/61631665/129976003-e55db5ba-bf42-448c-a059-26a009775e68.png)
- **정확한 음량**: 사용자 지정 HUD와 사용자 지정 음량 단계 및 마우스 휠/단축키를 사용하여 음량을 정확하게 제어하세요
- **영상 품질 체인저**: 영상 오버레이의 [버튼](https://user-images.githubusercontent.com/78568641/138574366-70324a5e-2d64-4f6a-acdd-dc2a2b9cecc5.png)으로 영상 품질을 변경할 수 있게 합니다
- **단축키 (& MPRIS)**: 재생을 위한 전역 단축키 설정 허용 (재생/일시 정지/다음/이전) + 미디어 키를 재정의하여 [미디어 osd](https://user-images.githubusercontent.com/84923831/128601225-afa38c1f-dea8-4209-9f72-0f84c1dd8b54.png) 비활성화 + Ctrl/CMD + F 검색 활성화 + 미디어 키에 대한 리눅스 MPRIS 지원 활성화 + [고급 사용자](https://github.com/th-ch/youtube-music/issues/106#issuecomment-952156902)를 위한 [사용자 지정 단축키](https://github.com/Araxeus/youtube-music/blob/1e591d6a3df98449bcda6e63baab249b28026148/providers/song-controls.js#L13-L50) 지원
- **무음 건너뛰기** - 노래의 무음 부분을 자동으로 건너뜁니다
- [**SponsorBlock**](https://github.com/ajayyy/SponsorBlock): 인트로/아웃트로와 같은 음악이 아닌 부분이나, 노래가 재생되지 않는 뮤직 비디오의 일부를 자동으로 건너뜁니다
- **작업표시줄 미디어 컨트롤**: [Windows 작업표시줄](https://user-images.githubusercontent.com/78568641/111916130-24a35e80-8a82-11eb-80c8-5021c1aa27f4.png)에서 재생을 제어하세요
- **TouchBar**: macOS 사용자를 위한 TouchBar 위젯을 추가합니다
- **Tuna-OBS**: [OBS](https://obsproject.com/)의 플러그인, [Tuna](https://obsproject.com/forum/resources/tuna.843/)와 통합을 활성화합니다
- **영상 전환**: 영상/노래 모드를 전환하는 [버튼](https://user-images.githubusercontent.com/28893833/173663950-63e6610e-a532-49b7-9afa-54cb57ddfc15.png)을 추가합니다. 선택적으로 전체 영상 탭을 제거할 수도 있습니다
- **비주얼라이저**: 플레이어에 시각화 도구 추가
## 테마
CSS 파일을 로드하여 애플리케이션의 모양을 변경할 수 있습니다(설정 > 시각적 변경 > 테마).
일부 사전 정의 테마는 https://github.com/kerichdev/themes-for-ytmdesktop-player 에서 사용할 수 있습니다.
## 개발
```bash
git clone https://github.com/th-ch/youtube-music
cd youtube-music
pnpm install --frozen-lockfile
pnpm dev
```
## 나만의 플러그인 만들기
플러그인을 사용하면 할 수 있는 것들:
- 앱 조작 - Electron에서 `BrowserWindow`가 플러그인 핸들러로 전달
- HTML/CSS를 조작하여 프론트엔드를 변경
### 플러그인 만들기
`plugins/나만의-플러그인-이름`에 폴더를 만듭니다:
- `index.ts`: 플러그인의 메인 파일입니다.
```typescript
import style from './style.css?inline'; // 스타일을 인라인으로 가져옵니다
import { createPlugin } from '@/utils';
export default createPlugin({
name: 'Plugin Label',
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
config: {
enabled: false,
}, // 나의 커스텀 config
stylesheets: [style], // 나의 스타일
menu: async ({ getConfig, setConfig }) => {
// 모든 *Config 메서드는 Promise<T>로 래핑됩니다
const config = await getConfig();
return [
{
label: 'menu',
submenu: [1, 2, 3].map((value) => ({
label: `value ${value}`,
type: 'radio',
checked: config.value === value,
click() {
setConfig({ value });
},
})),
},
];
},
backend: {
start({ window, ipc }) {
window.maximize();
// 이를 사용하여 렌더러 플러그인과 통신할 수 있습니다
ipc.handle('some-event', () => {
return 'hello';
});
},
// config가 변경되면 실행됩니다
onConfigChange(newConfig) { /* ... */ },
// 플러그인이 비활성화되면 실행됩니다
stop(context) { /* ... */ },
},
renderer: {
async start(context) {
console.log(await context.ipc.invoke('some-event'));
},
// 렌더러에서만 사용 가능한 훅입니다
onPlayerApiReady(api: YoutubePlayer, context: RendererContext<T>) {
// 플러그인의 config를 간단하게 설정할 수 있습니다
context.setConfig({ myConfig: api.getVolume() });
},
onConfigChange(newConfig) { /* ... */ },
stop(_context) { /* ... */ },
},
preload: {
async start({ getConfig }) {
const config = await getConfig();
},
onConfigChange(newConfig) {},
stop(_context) {},
},
});
```
### 일반적인 사용 예
- 사용자 정의 CSS 삽입: 같은 폴더에 `style.css` 파일을 생성합니다:
```typescript
// index.ts
import style from './style.css?inline'; // 스타일을 인라인으로 가져옵니다
import { createPlugin } from '@/utils';
const builder = createPlugin({
name: 'Plugin Label',
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
config: {
enabled: false,
}, // 나의 커스텀 config
stylesheets: [style], // 나의 커스텀 스타일
renderer() {} // 렌더러 훅 정의
});
```
- HTML을 변경하려는 경우:
```typescript
import { createPlugin } from '@/utils';
const builder = createPlugin({
name: 'Plugin Label',
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
config: {
enabled: false,
}, // 나의 커스텀 config
renderer() {
// 로그인 버튼을 제거합니다
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
} // 렌더러 훅 정의
});
```
- 프론트엔드와 백엔드 간의 통신: Electron의 `ipcMain` 모듈을 사용하여 수행할 수 있습니다. `SponsorBlock` 플러그인의 `index.ts` 파일과 예제를 참조하세요.
## 빌드
1. 레포지토리를 복제 (clone) 합니다
2. [이 가이드](https://pnpm.io/installation)에 따라 `pnpm`을 설치합니다.
3. `pnpm install --frozen-lockfile`을 실행하여 종속성을 설치합니다.
4. `pnpm build:OS`을 실행합니다.
- `pnpm dist:win` - Windows
- `pnpm dist:linux` - Linux
- `pnpm dist:mac` - MacOS
[electron-builder](https://github.com/electron-userland/electron-builder)를 사용하여 macOS, Linux 및 Windows용 앱을 빌드합니다.
## 프로덕션 빌드 미리보기
```bash
pnpm start
```
## 테스트
```bash
pnpm test
```
[Playwright](https://playwright.dev/)를 사용하여 앱을 테스트합니다.
## 라이선스
MIT © [th-ch](https://github.com/th-ch/youtube-music)
## 자주 묻는 질문
### 앱 메뉴가 표시되지 않는 이유는 무엇인가요?
`메뉴 숨기기` 옵션이 켜져 있는 경우 - <kbd>alt</kbd> 키(또는 인앱 메뉴 플러그인을 사용하는 경우 <kbd>\`</kbd> [백틱] 키)로 메뉴를 표시할 수 있습니다.

155
electron.vite.config.ts Normal file
View File

@ -0,0 +1,155 @@
import { resolve } from 'node:path';
import { defineConfig, defineViteConfig } from 'electron-vite';
import builtinModules from 'builtin-modules';
import viteResolve from 'vite-plugin-resolve';
import Inspect from 'vite-plugin-inspect';
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer';
import pluginLoader from './vite-plugins/plugin-loader';
import type { UserConfig } from 'vite';
import { i18nImporter } from './vite-plugins/i18n-importer';
const resolveAlias = {
'@': resolve(__dirname, './src'),
'@assets': resolve(__dirname, './assets'),
};
export default defineConfig({
main: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('backend'),
viteResolve({
'virtual:i18n': i18nImporter(),
'virtual:plugins': pluginVirtualModuleGenerator('main'),
}),
],
publicDir: 'assets',
build: {
lib: {
entry: 'src/index.ts',
formats: ['cjs'],
},
outDir: 'dist/main',
commonjsOptions: {
ignoreDynamicRequires: true,
},
rollupOptions: {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/index.ts',
},
},
resolve: {
alias: resolveAlias,
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/backend' }),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
preload: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('preload'),
viteResolve({
'virtual:i18n': i18nImporter(),
'virtual:plugins': pluginVirtualModuleGenerator('preload'),
}),
],
build: {
lib: {
entry: 'src/preload.ts',
formats: ['cjs'],
},
outDir: 'dist/preload',
commonjsOptions: {
ignoreDynamicRequires: true,
},
rollupOptions: {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/preload.ts',
},
},
resolve: {
alias: resolveAlias,
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/preload' }),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
renderer: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('renderer'),
viteResolve({
'virtual:i18n': i18nImporter(),
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
}),
],
root: './src/',
build: {
lib: {
entry: 'src/index.html',
formats: ['iife'],
name: 'renderer',
},
outDir: 'dist/renderer',
commonjsOptions: {
ignoreDynamicRequires: true,
},
rollupOptions: {
external: ['electron', ...builtinModules],
input: './src/index.html',
},
},
resolve: {
alias: resolveAlias,
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/renderer' }),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
});

View File

@ -1,9 +1,9 @@
{ {
"name": "youtube-music", "name": "youtube-music",
"productName": "YouTube Music", "productName": "YouTube Music",
"version": "2.2.0", "version": "3.0.2",
"description": "YouTube Music Desktop App - including custom plugins", "description": "YouTube Music Desktop App - including custom plugins",
"main": "./dist/index.js", "main": "./dist/main/index.js",
"license": "MIT", "license": "MIT",
"repository": "th-ch/youtube-music", "repository": "th-ch/youtube-music",
"author": { "author": {
@ -17,6 +17,7 @@
"files": [ "files": [
"!*", "!*",
"dist", "dist",
"assets",
"license", "license",
"!node_modules", "!node_modules",
"node_modules/custom-electron-prompt/**", "node_modules/custom-electron-prompt/**",
@ -25,6 +26,9 @@
"!node_modules/**/*.map", "!node_modules/**/*.map",
"!node_modules/**/*.ts" "!node_modules/**/*.ts"
], ],
"asarUnpack": [
"assets"
],
"mac": { "mac": {
"identity": null, "identity": null,
"target": [ "target": [
@ -89,23 +93,24 @@
"scripts": { "scripts": {
"test": "playwright test", "test": "playwright test",
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test", "test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
"rollup:preload": "rollup -c rollup.preload.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs", "build": "electron-vite build",
"rollup:main": "rollup -c rollup.main.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs", "vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
"build": "yarpm-pnpm run rollup:preload && yarpm-pnpm run rollup:main", "start": "electron-vite preview",
"start": "yarpm-pnpm run build && electron ./dist/index.js", "start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start", "dev": "electron-vite dev",
"clean": "del-cli dist && del-cli pack", "dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
"dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never", "clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
"dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p never", "dist": "pnpm clean && pnpm build && electron-builder --win --mac --linux -p never",
"dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never", "dist:linux": "pnpm clean && pnpm build && electron-builder --linux -p never",
"dist:mac:arm64": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:arm64 -p never", "dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never",
"dist:win": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win -p never", "dist:mac:arm64": "pnpm clean && pnpm build && electron-builder --mac dmg:arm64 -p never",
"dist:win:x64": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win nsis-web:x64 -p never", "dist:win": "pnpm clean && pnpm build && electron-builder --win -p never",
"dist:win:x64": "pnpm clean && pnpm build && electron-builder --win nsis-web:x64 -p never",
"lint": "eslint .", "lint": "eslint .",
"changelog": "npx --yes auto-changelog", "changelog": "npx --yes auto-changelog",
"release:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p always -c.snap.publish=github", "release:linux": "pnpm clean && pnpm build && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac -p always", "release:mac": "pnpm clean && pnpm build && electron-builder --mac -p always",
"release:win": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win -p always", "release:win": "pnpm clean && pnpm build && electron-builder --win -p always",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"engines": { "engines": {
@ -113,85 +118,86 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"rollup": "4.2.0", "usocket": "1.0.1",
"rollup": "4.6.1",
"node-gyp": "10.0.1", "node-gyp": "10.0.1",
"xml2js": "0.6.2", "xml2js": "0.6.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"@electron/universal": "1.4.4", "@electron/universal": "2.0.0",
"@babel/runtime": "7.23.2" "@babel/runtime": "7.23.2"
} }
}, },
"overrides": {
"rollup": "4.2.0",
"node-gyp": "10.0.1",
"xml2js": "0.6.2",
"node-fetch": "3.3.2",
"@electron/universal": "1.4.4",
"@babel/runtime": "7.23.2"
},
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "1.26.10", "@cliqz/adblocker-electron": "1.26.12",
"@cliqz/adblocker-electron-preload": "1.26.10", "@cliqz/adblocker-electron-preload": "1.26.12",
"@electron-toolkit/tsconfig": "1.0.1",
"@electron/remote": "2.1.0",
"@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0", "@ffmpeg.wasm/main": "0.12.0",
"@foobar404/wave": "2.0.4", "@foobar404/wave": "2.0.4",
"@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/electron-better-web-request": "1.0.4",
"@jellybrick/mpris-service": "2.1.4", "@jellybrick/mpris-service": "2.1.4",
"@xhayper/discord-rpc": "1.0.24", "@xhayper/discord-rpc": "1.1.1",
"async-mutex": "0.4.0", "async-mutex": "0.4.0",
"butterchurn": "3.0.0-beta.4", "butterchurn": "3.0.0-beta.4",
"butterchurn-presets": "3.0.0-beta.4", "butterchurn-presets": "3.0.0-beta.4",
"conf": "10.2.0", "conf": "10.2.0",
"custom-electron-prompt": "1.5.7", "custom-electron-prompt": "1.5.7",
"dbus-next": "0.10.2",
"deepmerge-ts": "5.1.0",
"electron-debug": "3.2.0", "electron-debug": "3.2.0",
"electron-is": "3.0.0", "electron-is": "3.0.0",
"electron-localshortcut": "3.2.1", "electron-localshortcut": "3.2.1",
"electron-store": "8.1.0", "electron-store": "8.1.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-updater": "6.1.4", "electron-updater": "6.1.7",
"fast-average-color": "9.4.0", "fast-average-color": "9.4.0",
"fast-equals": "5.0.1",
"filenamify": "6.0.0", "filenamify": "6.0.0",
"howler": "2.2.4", "howler": "2.2.4",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"i18next": "23.7.7",
"keyboardevent-from-electron-accelerator": "2.0.0", "keyboardevent-from-electron-accelerator": "2.0.0",
"keyboardevents-areequal": "0.2.2", "keyboardevents-areequal": "0.2.2",
"node-html-parser": "6.1.11",
"node-id3": "0.2.6", "node-id3": "0.2.6",
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8", "serve": "14.2.1",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
"ts-morph": "20.0.0",
"vudio": "2.1.1", "vudio": "2.1.1",
"x11": "2.3.0", "x11": "2.3.0",
"youtubei.js": "7.0.0" "youtubei.js": "8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.39.0", "@playwright/test": "1.40.1",
"@rollup/plugin-commonjs": "25.0.7",
"@rollup/plugin-image": "3.0.3",
"@rollup/plugin-json": "6.0.1",
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-terser": "0.4.4",
"@rollup/plugin-typescript": "11.1.5",
"@rollup/plugin-wasm": "6.2.2",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "3.1.2", "@types/electron-localshortcut": "3.1.3",
"@types/howler": "2.2.10", "@types/howler": "2.2.11",
"@types/html-to-text": "9.0.3", "@types/html-to-text": "9.0.4",
"@typescript-eslint/eslint-plugin": "6.9.1", "@typescript-eslint/eslint-plugin": "6.13.1",
"builtin-modules": "^3.3.0", "bufferutil": "4.0.8",
"builtin-modules": "3.3.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"del-cli": "5.1.0", "del-cli": "5.1.0",
"electron": "27.0.3", "electron": "27.1.3",
"electron-builder": "24.6.4", "electron-builder": "24.9.1",
"electron-devtools-installer": "3.2.0", "electron-devtools-installer": "3.2.0",
"eslint": "8.52.0", "electron-vite": "1.0.29",
"eslint": "8.55.0",
"eslint-import-resolver-exports": "1.0.0-beta.5",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"eslint-plugin-prettier": "5.0.1", "eslint-plugin-prettier": "5.0.1",
"glob": "10.3.10",
"node-gyp": "10.0.1", "node-gyp": "10.0.1",
"playwright": "1.39.0", "playwright": "1.40.1",
"rollup": "4.2.0", "rollup": "4.6.1",
"rollup-plugin-copy": "3.5.0", "typescript": "5.3.2",
"rollup-plugin-import-css": "3.3.5", "utf-8-validate": "6.0.3",
"rollup-plugin-string": "3.0.0", "vite": "4.5.0",
"typescript": "5.2.2", "vite-plugin-inspect": "0.8.1",
"yarpm": "1.2.0" "vite-plugin-resolve": "2.5.1",
"ws": "8.14.2"
}, },
"auto-changelog": { "auto-changelog": {
"hideCredit": true, "hideCredit": true,
@ -199,5 +205,5 @@
"unreleased": true, "unreleased": true,
"output": "changelog.md" "output": "changelog.md"
}, },
"packageManager": "pnpm@8.10.2" "packageManager": "pnpm@8.11.0"
} }

2595
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

8
renovate.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"labels": ["dependencies"],
"postUpdateOptions": ["pnpmDedupe"]
}

View File

@ -1,60 +0,0 @@
import { defineConfig } from 'rollup';
import builtinModules from 'builtin-modules';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import { string } from 'rollup-plugin-string';
import css from 'rollup-plugin-import-css';
import wasmPlugin from '@rollup/plugin-wasm';
import copy from 'rollup-plugin-copy';
export default defineConfig({
plugins: [
typescript({
module: 'ESNext',
}),
nodeResolvePlugin({
browser: false,
preferBuiltins: true,
exportConditions: ['node', 'default', 'module', 'import'],
}),
commonjs({
ignoreDynamicRequires: true,
}),
wasmPlugin({
maxFileSize: 0,
targetEnv: 'browser',
}),
json(),
string({
include: '**/*.html',
}),
css(),
copy({
targets: [
{ src: 'src/error.html', dest: 'dist/' },
{ src: 'assets', dest: 'dist/' },
],
}),
terser({
ecma: 2020,
}),
{
closeBundle() {
if (!process.env.ROLLUP_WATCH) {
setTimeout(() => process.exit(0));
}
},
name: 'force-close',
},
],
input: './src/index.ts',
output: {
format: 'cjs',
name: '[name].js',
dir: './dist',
},
external: ['electron', 'custom-electron-prompt', ...builtinModules],
});

View File

@ -1,54 +0,0 @@
import { defineConfig } from 'rollup';
import builtinModules from 'builtin-modules';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import { string } from 'rollup-plugin-string';
import css from 'rollup-plugin-import-css';
import wasmPlugin from '@rollup/plugin-wasm';
import image from '@rollup/plugin-image';
export default defineConfig({
plugins: [
typescript({
module: 'ESNext',
}),
nodeResolvePlugin({
browser: false,
preferBuiltins: true,
}),
commonjs({
ignoreDynamicRequires: true,
}),
json(),
string({
include: '**/*.html',
}),
css(),
wasmPlugin({
maxFileSize: 0,
targetEnv: 'browser',
}),
image({ dom: true }),
terser({
ecma: 2020,
}),
{
closeBundle() {
if (!process.env.ROLLUP_WATCH) {
setTimeout(() => process.exit(0));
}
},
name: 'force-close',
},
],
input: './src/preload.ts',
output: {
format: 'cjs',
name: '[name].js',
dir: './dist',
},
external: ['electron', 'custom-electron-prompt', ...builtinModules],
});

View File

@ -1,24 +1,20 @@
import { blockers } from '../plugins/adblocker/blocker-types';
import { DefaultPresetList } from '../plugins/downloader/types';
export interface WindowSizeConfig { export interface WindowSizeConfig {
width: number; width: number;
height: number; height: number;
} }
export interface WindowPositionConfig {
x: number;
y: number;
}
export interface DefaultConfig { export interface DefaultConfig {
'window-size': { 'window-size': WindowSizeConfig;
width: number;
height: number;
}
'window-maximized': boolean; 'window-maximized': boolean;
'window-position': { 'window-position': WindowPositionConfig;
x: number;
y: number;
}
url: string; url: string;
options: { options: {
language?: string;
tray: boolean; tray: boolean;
appVisible: boolean; appVisible: boolean;
autoUpdates: boolean; autoUpdates: boolean;
@ -37,10 +33,11 @@ export interface DefaultConfig {
startingPage: string; startingPage: string;
overrideUserAgent: boolean; overrideUserAgent: boolean;
themes: string[]; themes: string[];
} };
plugins: Record<string, unknown>;
} }
const defaultConfig = { const defaultConfig: DefaultConfig = {
'window-size': { 'window-size': {
width: 1100, width: 1100,
height: 550, height: 550,
@ -69,230 +66,9 @@ const defaultConfig = {
proxy: '', proxy: '',
startingPage: '', startingPage: '',
overrideUserAgent: false, overrideUserAgent: false,
themes: [] as string[], themes: [],
},
/** please order alphabetically */
'plugins': {
'adblocker': {
enabled: true,
cache: true,
blocker: blockers.InPlayer as string,
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
disableDefaultLists: false,
},
'album-color-theme': {},
'ambient-mode': {
enabled: false,
quality: 50,
buffer: 30,
interpolationTime: 1500,
blur: 100,
size: 100,
opacity: 1,
fullscreen: false,
},
'audio-compressor': {},
'blur-nav-bar': {},
'bypass-age-restrictions': {},
'captions-selector': {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
'compact-sidebar': {},
'crossfade': {
enabled: false,
fadeInDuration: 1500, // Ms
fadeOutDuration: 5000, // Ms
secondsBeforeEnd: 10, // S
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
},
'disable-autoplay': {
applyOnce: false,
},
'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
playOnYouTubeMusic: true, // Add a "Play on YouTube Music" button to rich presence
hideGitHubButton: false, // Disable the "View App On GitHub" button
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
},
'downloader': {
enabled: false,
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
},
'http-api': {},
'exponential-volume': {},
'in-app-menu': {
/**
* true in Windows, false in Linux and macOS (see youtube-music/config/store.ts)
*/
enabled: false,
hideDOMWindowControls: false,
},
'last-fm': {
enabled: false,
token: undefined as string | undefined, // Token used for authentication
session_key: undefined as string | undefined, // Session key used for scrobbling
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2',
},
'lumiastream': {},
'lyrics-genius': {
romanizedLyrics: false,
},
'navigation': {
enabled: true,
},
'no-google-login': {},
'notifications': {
enabled: false,
unpauseNotification: false,
urgency: 'normal', // Has effect only on Linux
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
},
'picture-in-picture': {
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': false,
},
'playback-speed': {},
'precise-volume': {
enabled: false,
steps: 1, // Percentage of volume to change
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
globalShortcuts: {
volumeUp: '',
volumeDown: '',
},
savedVolume: undefined as number | undefined, // Plugin save volume between session here
},
'quality-changer': {},
'shortcuts': {
enabled: false,
overrideMediaKeys: false,
global: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
local: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
},
'skip-silences': {
onlySkipBeginning: false,
},
'sponsorblock': {
enabled: false,
apiURL: 'https://sponsor.ajay.app',
categories: [
'sponsor',
'intro',
'outro',
'interaction',
'selfpromo',
'music_offtopic',
],
},
'taskbar-mediacontrol': {},
'touchbar': {},
'tuna-obs': {},
'video-toggle': {
enabled: false,
hideVideo: false,
mode: 'custom',
forceHide: false,
align: '',
},
'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',
},
},
],
},
},
}, },
'plugins': {},
}; };
export default defaultConfig; export default defaultConfig;

View File

@ -1,241 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import { ipcMain, ipcRenderer } from 'electron';
import defaultConfig from './defaults';
import { getOptions, setMenuOptions, setOptions } from './plugins';
import { sendToFront } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const getActivePlugins
= process.type === 'renderer'
? async () => ipcRenderer.invoke('get-active-plugins')
: () => activePlugins;
if (process.type === 'browser') {
ipcMain.handle('get-active-plugins', getActivePlugins);
}
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const isActive
= process.type === 'renderer'
? async (plugin: string) =>
plugin in (await ipcRenderer.invoke('get-active-plugins'))
: (plugin: string): boolean => plugin in activePlugins;
interface PluginConfigOptions {
enableFront: boolean;
initialOptions?: OneOfDefaultConfig;
}
/**
* 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);
* };
*/
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T];
type Mode<T, Mode extends 'r' | 'm'> = Mode extends 'r' ? Promise<T> : T;
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private readonly name: string;
private readonly config: ConfigType<T>;
private readonly defaultConfig: ConfigType<T>;
private readonly enableFront: boolean;
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
constructor(
name: T,
options: PluginConfigOptions = {
enableFront: false,
},
) {
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
const pluginConfig = options.initialOptions || getOptions(name) || {};
this.name = name;
this.enableFront = options.enableFront;
this.defaultConfig = pluginDefaultConfig;
this.config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.enableFront) {
this.setupFront();
}
activePlugins[name] = this;
}
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
return this.config?.[key];
}
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
this.onChange(key);
this.save();
}
getAll(): ConfigType<T> {
return { ...this.config };
}
setAll(options: Partial<ConfigType<T>>) {
if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.');
}
let changed = false;
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
if (this.config[key] !== value) {
if (value !== undefined) this.config[key] = value;
this.onChange(key, false);
changed = true;
}
}
if (changed) {
for (const fn of this.allSubscribers) {
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(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
setMenuOptions(this.name, this.config);
this.onChange(key);
}
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
this.subscribers[valueName] = fn;
}
subscribeAll(fn: (config: ConfigType<T>) => void) {
this.allSubscribers.push(fn);
}
/** Called only from back */
private save() {
setOptions(this.name, this.config);
}
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
if (single) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
}
private setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
if (process.type === 'renderer') {
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return
this[fnName] = (async (...args: any) => await ipcRenderer.invoke(
`${this.name}-config-${String(fnName)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
...args,
)) as typeof this[keyof this];
this.subscribe = (valueName, fn: (config: ConfigType<T>) => void) => {
if (valueName in this.subscribers) {
console.error(`Already subscribed to ${String(valueName)}`);
}
this.subscribers[valueName] = fn;
ipcRenderer.on(
`${this.name}-config-changed-${String(valueName)}`,
(_, value: ConfigType<T>) => {
fn(value);
},
);
ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
fn(value);
});
ipcRenderer.send(`${this.name}-config-subscribe-all`);
};
}
} else if (process.type === 'browser') {
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return
ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args));
}
ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType<T>) => {
this.subscribe(valueName, (value) => {
sendToFront(`${this.name}-config-changed-${String(valueName)}`, value);
});
});
ipcMain.on(`${this.name}-config-subscribe-all`, () => {
this.subscribeAll((value) => {
sendToFront(`${this.name}-config-changed`, value);
});
});
}
}
}

View File

@ -1,15 +1,20 @@
import Store from 'electron-store'; import Store from 'electron-store';
import { deepmerge } from 'deepmerge-ts';
import defaultConfig from './defaults'; import defaultConfig from './defaults';
import plugins from './plugins';
import store from './store'; import store from './store';
import plugins from './plugins';
import { restart } from '../providers/app-controls'; import { restart } from '@/providers/app-controls';
const set = (key: string, value: unknown) => { const set = (key: string, value: unknown) => {
store.set(key, value); store.set(key, value);
}; };
const setPartial = (key: string, value: object, defaultValue?: object) => {
const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
store.set(key, newValue);
};
function setMenuOption(key: string, value: unknown) { function setMenuOption(key: string, value: unknown) {
set(key, value); set(key, value);
@ -20,34 +25,65 @@ function setMenuOption(key: string, value: unknown) {
// MAGIC OF TYPESCRIPT // MAGIC OF TYPESCRIPT
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, type Prev = [
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]] never,
type Join<K, P> = K extends string | number ? 0,
P extends string | number ? 1,
`${K}${'' extends P ? '' : '.'}${P}` 2,
: never : never; 3,
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ? 4,
{ [K in keyof T]-?: K extends string | number ? 5,
`${K}` | Join<K, Paths<T[K], Prev[D]>> 6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
...0[],
];
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never : never
}[keyof T] : '' : never;
type Paths<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K], Prev[D]>>
: never;
}[keyof T]
: '';
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string]; type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
type PathValue<T, K extends string> = type PathValue<T, K extends string> = SplitKey<K> extends [
SplitKey<K> extends [infer A extends keyof T, infer B extends string] infer A extends keyof T,
? PathValue<T[A], B> infer B extends string,
: T; ]
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>; ? PathValue<T[A], B>
: T;
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
store.get(key) as PathValue<typeof defaultConfig, typeof key>;
export default { export default {
defaultConfig, defaultConfig,
get, get,
set, set,
setPartial,
setMenuOption, setMenuOption,
edit: () => store.openInEditor(), edit: () => store.openInEditor(),
watch(cb: Parameters<Store['onDidChange']>[1]) { watch(cb: Parameters<Store['onDidAnyChange']>[0]) {
store.onDidChange('options', cb); store.onDidAnyChange(cb);
store.onDidChange('plugins', cb);
}, },
plugins, plugins,
}; };

View File

@ -1,24 +1,21 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import store from './store'; import store from './store';
import defaultConfig from './defaults';
import { restart } from '../providers/app-controls'; import { restart } from '@/providers/app-controls';
import { Entries } from '../utils/type-utils';
interface Plugin { import type { PluginConfig } from '@/types/plugins';
enabled: boolean;
}
type DefaultPluginsConfig = typeof defaultConfig.plugins; export function getPlugins() {
return store.get('plugins') as Record<string, PluginConfig>;
export function getEnabled() {
const plugins = store.get('plugins') as DefaultPluginsConfig;
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
isEnabled(plugin),
);
} }
export function isEnabled(plugin: string) { export function isEnabled(plugin: string) {
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin]; const pluginConfig = deepmerge(
allPlugins[plugin].config ?? { enabled: false },
(store.get('plugins') as Record<string, PluginConfig>)[plugin] ?? {},
);
return pluginConfig !== undefined && pluginConfig.enabled; return pluginConfig !== undefined && pluginConfig.enabled;
} }
@ -28,7 +25,11 @@ export function isEnabled(plugin: string) {
* @param options Options to set * @param options Options to set
* @param exclude Options to exclude from the options object * @param exclude Options to exclude from the options object
*/ */
export function setOptions<T>(plugin: string, options: T, exclude: string[] = ['enabled']) { export function setOptions<T>(
plugin: string,
options: T,
exclude: string[] = ['enabled'],
) {
const plugins = store.get('plugins') as Record<string, T>; const plugins = store.get('plugins') as Record<string, T>;
// HACK: This is a workaround for preventing changed options from being overwritten // HACK: This is a workaround for preventing changed options from being overwritten
exclude.forEach((key) => { exclude.forEach((key) => {
@ -45,7 +46,11 @@ export function setOptions<T>(plugin: string, options: T, exclude: string[] = ['
}); });
} }
export function setMenuOptions<T>(plugin: string, options: T, exclude: string[] = ['enabled']) { export function setMenuOptions<T>(
plugin: string,
options: T,
exclude: string[] = ['enabled'],
) {
setOptions(plugin, options, exclude); setOptions(plugin, options, exclude);
if (store.get('options.restartOnConfigChanges')) { if (store.get('options.restartOnConfigChanges')) {
restart(); restart();
@ -66,7 +71,7 @@ export function disable(plugin: string) {
export default { export default {
isEnabled, isEnabled,
getEnabled, getPlugins,
enable, enable,
disable, disable,
setOptions, setOptions,

View File

@ -1,25 +1,33 @@
import Store from 'electron-store'; import Store from 'electron-store';
import Conf from 'conf'; import Conf from 'conf';
import is from 'electron-is';
import defaults from './defaults'; import defaults from './defaults';
import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
const getDefaults = () => {
if (is.windows()) {
defaults.plugins['in-app-menu'].enabled = true;
}
return defaults;
};
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof defaults.plugins) => {
if (!store.get(`plugins.${plugin}`)) {
store.set(`plugins.${plugin}`, defaults.plugins[plugin]);
}
};
const migrations = { const migrations = {
'>=3.0.0'(store: Conf<Record<string, unknown>>) {
const discordConfig = store.get('plugins.discord') as Record<
string,
unknown
>;
if (discordConfig) {
const oldActivityTimoutEnabled = store.get(
'plugins.discord.activityTimoutEnabled',
) as boolean | undefined;
const oldActivityTimoutTime = store.get(
'plugins.discord.activityTimoutTime',
) as number | undefined;
if (oldActivityTimoutEnabled !== undefined) {
discordConfig.activityTimeoutEnabled = oldActivityTimoutEnabled;
store.set('plugins.discord', discordConfig);
}
if (oldActivityTimoutTime !== undefined) {
discordConfig.activityTimeoutTime = oldActivityTimoutTime;
store.set('plugins.discord', discordConfig);
}
}
},
'>=2.1.3'(store: Conf<Record<string, unknown>>) { '>=2.1.3'(store: Conf<Record<string, unknown>>) {
const listenAlong = store.get('plugins.discord.listenAlong'); const listenAlong = store.get('plugins.discord.listenAlong');
if (listenAlong !== undefined) { if (listenAlong !== undefined) {
@ -28,19 +36,24 @@ const migrations = {
} }
}, },
'>=2.1.0'(store: Conf<Record<string, unknown>>) { '>=2.1.0'(store: Conf<Record<string, unknown>>) {
const originalPreset = store.get('plugins.downloader.preset') as string | undefined; const originalPreset = store.get('plugins.downloader.preset') as
| string
| undefined;
if (originalPreset) { if (originalPreset) {
if (originalPreset !== 'opus') { if (originalPreset !== 'opus') {
store.set('plugins.downloader.selectedPreset', 'Custom'); store.set('plugins.downloader.selectedPreset', 'Custom');
store.set('plugins.downloader.customPresetSetting', { store.set('plugins.downloader.customPresetSetting', {
extension: 'mp3', extension: 'mp3',
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs, ffmpegArgs:
(store.get('plugins.downloader.ffmpegArgs') as string[]) ??
DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
} satisfies Preset); } satisfies Preset);
} else { } else {
store.set('plugins.downloader.selectedPreset', 'Source'); store.set('plugins.downloader.selectedPreset', 'Source');
store.set('plugins.downloader.customPresetSetting', { store.set('plugins.downloader.customPresetSetting', {
extension: null, extension: null,
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [], ffmpegArgs:
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
} satisfies Preset); } satisfies Preset);
} }
store.delete('plugins.downloader.preset'); store.delete('plugins.downloader.preset');
@ -48,12 +61,11 @@ const migrations = {
} }
}, },
'>=1.20.0'(store: Conf<Record<string, unknown>>) { '>=1.20.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'visualizer'); store.delete('plugins.visualizer'); // default value is now in the plugin
if (store.get('plugins.notifications.toastStyle') === undefined) { if (store.get('plugins.notifications.toastStyle') === undefined) {
const pluginOptions = store.get('plugins.notifications') || {}; const pluginOptions = store.get('plugins.notifications') || {};
store.set('plugins.notifications', { store.set('plugins.notifications', {
...defaults.plugins.notifications,
...pluginOptions, ...pluginOptions,
}); });
} }
@ -64,7 +76,7 @@ const migrations = {
} }
}, },
'>=1.17.0'(store: Conf<Record<string, unknown>>) { '>=1.17.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'picture-in-picture'); store.delete('plugins.picture-in-picture'); // default value is now in the plugin
if (store.get('plugins.video-toggle.mode') === undefined) { if (store.get('plugins.video-toggle.mode') === undefined) {
store.set('plugins.video-toggle.mode', 'custom'); store.set('plugins.video-toggle.mode', 'custom');
@ -88,31 +100,41 @@ const migrations = {
} }
}, },
'>=1.12.0'(store: Conf<Record<string, unknown>>) { '>=1.12.0'(store: Conf<Record<string, unknown>>) {
const options = store.get('plugins.shortcuts') as Record<string, { const options = store.get('plugins.shortcuts') as
action: string; | Record<
shortcut: unknown; string,
}[] | Record<string, unknown>>; | {
let updated = false; action: string;
for (const optionType of ['global', 'local']) { shortcut: unknown;
if (Array.isArray(options[optionType])) { }[]
const optionsArray = options[optionType] as { | Record<string, unknown>
action: string; >
shortcut: unknown; | undefined;
}[]; if (options) {
const updatedOptions: Record<string, unknown> = {}; let updated = false;
for (const optionObject of optionsArray) { for (const optionType of ['global', 'local']) {
if (optionObject.action && optionObject.shortcut) { if (
updatedOptions[optionObject.action] = optionObject.shortcut; Object.hasOwn(options, optionType) &&
Array.isArray(options[optionType])
) {
const optionsArray = options[optionType] as {
action: string;
shortcut: unknown;
}[];
const updatedOptions: Record<string, unknown> = {};
for (const optionObject of optionsArray) {
if (optionObject.action && optionObject.shortcut) {
updatedOptions[optionObject.action] = optionObject.shortcut;
}
} }
options[optionType] = updatedOptions;
updated = true;
} }
options[optionType] = updatedOptions;
updated = true;
} }
} if (updated) {
store.set('plugins.shortcuts', options);
if (updated) { }
store.set('plugins.shortcuts', options);
} }
}, },
'>=1.11.0'(store: Conf<Record<string, unknown>>) { '>=1.11.0'(store: Conf<Record<string, unknown>>) {
@ -155,7 +177,10 @@ const migrations = {
}; };
export default new Store({ export default new Store({
defaults: getDefaults(), defaults: {
...defaults,
// README: 'plugin' uses deepmerge to populate the default values, so it is not necessary to include it here
},
clearInvalidConfig: false, clearInvalidConfig: false,
migrations, migrations,
}); });

View File

@ -53,33 +53,45 @@ declare module 'custom-electron-prompt' {
export interface CounterPromptOptions extends BasePromptOptions<'counter'> { export interface CounterPromptOptions extends BasePromptOptions<'counter'> {
counterOptions: CounterOptions; counterOptions: CounterOptions;
} }
export interface MultiInputPromptOptions extends BasePromptOptions<'multiInput'> { export interface MultiInputPromptOptions
extends BasePromptOptions<'multiInput'> {
multiInputOptions: InputOptions[]; multiInputOptions: InputOptions[];
} }
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> { export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
keybindOptions: KeybindOptions[]; keybindOptions: KeybindOptions[];
} }
export type PromptOptions<T extends string> = ( export type PromptOptions<T extends string> = T extends 'input'
T extends 'input' ? InputPromptOptions : ? InputPromptOptions
T extends 'select' ? SelectPromptOptions : : T extends 'select'
T extends 'counter' ? CounterPromptOptions : ? SelectPromptOptions
T extends 'keybind' ? KeybindPromptOptions : : T extends 'counter'
T extends 'multiInput' ? MultiInputPromptOptions : ? CounterPromptOptions
never : T extends 'keybind'
); ? KeybindPromptOptions
: T extends 'multiInput'
? MultiInputPromptOptions
: never;
type PromptResult<T extends string> = T extends 'input' ? string : type PromptResult<T extends string> = T extends 'input'
T extends 'select' ? string : ? string
T extends 'counter' ? number : : T extends 'select'
T extends 'keybind' ? { ? string
value: string; : T extends 'counter'
accelerator: string ? number
}[] : : T extends 'keybind'
T extends 'multiInput' ? string[] : ? {
never; value: string;
accelerator: string;
}[]
: T extends 'multiInput'
? string[]
: never;
const prompt: <T extends Type>(options?: PromptOptions<T> & { type: T }, parent?: BrowserWindow) => Promise<PromptResult<T> | null>; const prompt: <T extends Type>(
options?: PromptOptions<T> & { type: T },
parent?: BrowserWindow,
) => Promise<PromptResult<T> | null>;
export default prompt; export default prompt;
} }

View File

@ -1,50 +1,50 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8" />
<title>Cannot load YouTube Music</title> <title>Cannot load YouTube Music</title>
<style> <style>
body { body {
background: #000; background: #000;
} }
.container { .container {
margin: 0; margin: 0;
font-family: Roboto, Arial, sans-serif; font-family: Roboto, Arial, sans-serif;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
margin-right: -50%; margin-right: -50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
text-align: center; text-align: center;
} }
.button { .button {
background: #065fd4; background: #065fd4;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: white; color: white;
font: inherit; font: inherit;
text-transform: uppercase; text-transform: uppercase;
text-decoration: none; text-decoration: none;
border-radius: 2px; border-radius: 2px;
font-size: 16px; font-size: 16px;
font-weight: normal; font-weight: normal;
text-align: center; text-align: center;
padding: 8px 22px; padding: 8px 22px;
display: inline-block; display: inline-block;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<p>Cannot load YouTube Music… Internet disconnected?</p> <p>Cannot load YouTube Music… Internet disconnected?</p>
<a class="button" href="#" onclick="reload()">Retry</a> <a class="button" href="#" onclick="reload()">Retry</a>
</div> </div>
</body> </body>
</html> </html>

18
src/i18n/index.ts Normal file
View File

@ -0,0 +1,18 @@
import i18next, { init, t as i18t, changeLanguage } from 'i18next';
import { languageResources } from 'virtual:i18n';
export const loadI18n = async () =>
await init({
resources: languageResources,
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export const setLanguage = async (language: string) =>
await changeLanguage(language);
export const t = i18t.bind(i18next);

View File

@ -0,0 +1,11 @@
export interface LanguageResources {
[lang: string]: {
translation: Record<string, unknown> & {
language?: {
name: string;
'local-name': string;
code: string;
};
};
};
}

425
src/i18n/resources/cs.json Normal file
View File

@ -0,0 +1,425 @@
{
"common": {
"console": {
"plugins": {
"load-all": "Načítání všech pluginů",
"loaded": "Plugin \"{{pluginName}}\" načten"
}
}
},
"language": {
"code": "cs",
"local-name": "Čeština",
"name": "Czech"
},
"main": {
"console": {
"i18n": {
"loaded": "i18n načteno"
},
"second-instance": {
"receive-command": "Received command přes protokol: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS soubor \"{{cssFile}}\" neexistuje, ignoring"
},
"when-ready": {
"clearing-cache-after-20s": "Čištění mezipaměti aplikace"
}
},
"dialog": {
"need-to-restart": {
"buttons": {
"later": "Později",
"restart-now": "Restartovat nyní"
}
},
"unresponsive": {
"buttons": {
"quit": "Ukončení",
"relaunch": "Spustit znovu",
"wait": "Počkat"
},
"detail": "Omlouváme se za způsobené nepříjemnosti! prosím vyberte, co dělat:",
"message": "Aplikace nereaguje"
},
"update-available": {
"buttons": {
"disable": "Vypnout aktualizace",
"download": "Stáhnout",
"ok": "OK"
},
"message": "Nová verze je dostupná",
"title": "Aktualizace k dispozici"
}
},
"menu": {
"about": "O Aplikaci",
"navigation": {
"label": "Navigace",
"submenu": {
"copy-current-url": "Kopírovat aktuální URL adresu",
"go-back": "Jít zpátky",
"go-forward": "Jít dopředu",
"restart": "Restartovat aplikaci"
}
},
"options": {
"label": "Možnosti",
"submenu": {
"advanced-options": {
"label": "Pokročilé možnosti",
"submenu": {
"disable-hardware-acceleration": "Vypnout hardware zrychlení",
"edit-config-json": "Upravit config.json",
"override-user-agent": "Přepsat User-Agent",
"restart-on-config-changes": "Restartovat na změny v configu",
"set-proxy": {
"label": "Nastavit proxy",
"prompt": {
"placeholder": "Příklad: socks5://127.0.0.1:9999",
"title": "Nastavit proxy"
}
}
}
},
"always-on-top": "Vždy na vrchu",
"auto-update": "Automatické aktualizace",
"hide-menu": {
"label": "Skrýt menu"
},
"language": {
"dialog": {
"message": "Jazyk bude změněn po restartu",
"title": "Jazyk změněn"
},
"label": "Jazyk",
"submenu": {
"to-help-translate": "Chcete pomoc s překladem? Klikněte zde"
}
},
"tray": {
"submenu": {
"enabled-and-hide-app": "Povolit a skrýt aplikaci",
"play-pause-on-click": "Přehrát/Pozastavit na kliknutí"
}
},
"visual-tweaks": {
"submenu": {
"like-buttons": {
"default": "Výchozí",
"hide": "Schovat",
"label": "Like tlačítka"
},
"remove-upgrade-button": "Remove upgrade tlačítko",
"theme": {
"label": "Motiv",
"submenu": {
"import-css-file": "Import custom CSS soubor",
"no-theme": "Žádný motiv"
}
}
}
}
}
},
"plugins": {
"enabled": "Povoleno",
"label": "Pluginy"
},
"view": {
"submenu": {
"zoom-in": "Přiblížit",
"zoom-out": "Oddálit"
}
}
},
"tray": {
"next": "Další",
"play-pause": "Hrát/Zastavit",
"previous": "Minulý",
"quit": "Ukončit",
"restart": "Restartovat aplikaci",
"show": "Ukázat okno"
}
},
"plugins": {
"adblocker": {
"description": "Blokuje všechny reklamy a sledování ihned od začátku",
"menu": {
"blocker": "Blokátor"
},
"name": "Blokovač reklam"
},
"album-color-theme": {
"description": "Použije dynamický motiv a visuální efekty based na paletě barev alba",
"name": "Album Color Motiv"
},
"ambient-mode": {
"description": "Applies a lighting efekty by casting gentle colors z videa, into your screens pozadí.",
"menu": {
"blur-amount": {
"submenu": {
"pixels": "{{blurAmount}} pixelů"
}
},
"buffer": {
"label": "Vyrovnávací paměť",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Neprůhlednost",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Kvalita",
"submenu": {
"pixels": "{{quality}} pixelů"
}
},
"size": {
"label": "Velikost",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Plynulý přechod",
"submenu": {
"during": "Během {{interpolationTime}}s"
}
}
},
"name": "Ambientní režim"
},
"blur-nav-bar": {
"description": "Udělá navigační panel průhledným a rozmazaným"
},
"bypass-age-restrictions": {
"description": "Obejít ověření věku na YouTube",
"name": "Obejít věková omezení"
},
"captions-selector": {
"description": "Titulkový selector pro YouTube Music audio tracks",
"menu": {
"autoload": "Automaticky vybrat naposledy použité titulky",
"disable-captions": "Žádné titulky ve vychozím nastavení"
},
"name": "Titulkový selector",
"prompt": {
"selector": {
"label": "Aktuální jazyk titulků: {{language}}",
"none": "Žádný",
"title": "Vybrat jazyk titulků"
}
},
"templates": {
"title": "Otevřít titulový selector"
}
},
"compact-sidebar": {
"description": "Vždy set the sidebar in compact mode"
},
"crossfade": {
"description": "Crossfade mezi písničkami",
"menu": {
"advanced": "Pokročilý"
},
"prompt": {
"options": {
"multi-input": {
"fade-scaling": {
"linear": "Lineární",
"logarithmic": "Logaritmické"
},
"seconds-before-end": "Crossfade N sekund před koncem"
},
"title": "Možnosti prolínání"
}
}
},
"disable-autoplay": {
"name": "Zrušit automatické přehrávání"
},
"discord": {
"backend": {
"connected": "Připojeno k Discordu",
"disconnected": "Odpojeno od Discordu"
},
"description": "Ukažte svým přátelům, co posloucháte s Rich Presence",
"menu": {
"connected": "Připojeno",
"disconnected": "Odpojeno",
"hide-github-button": "Skrýt tlačítko s odkazem na GitHub",
"play-on-youtube-music": "Hrát na YouTube Music"
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Argh! Omlouvám se, stáhnutí selhalo…",
"title": "Chyba ve stáhování!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{playlistSize}} písničky)",
"message": "Stahování Playlistu {{playlistTitle}}",
"title": "Stahování začalo"
}
},
"feedback": {
"done": "Hotovo: {{filePath}}",
"download-info": "Stahování {{artist}} - {{title}} [{{videoId}}",
"downloading": "Stahování…",
"downloading-counter": "Stahování {{current}}/{{total}}…",
"downloading-playlist": "Downloading playlist \"{{playlistTitle}}\" - {{playlistSize}} písničky ({{playlistId}})",
"folder-already-exists": "Složka {{playlistFolder}} již existuje",
"loading": "Načítání…",
"playlist-has-only-one-song": "Playlist má jenom jeden položku, downloading it directly",
"playlist-id-not-found": "Žádný playlist ID nenalezen",
"playlist-is-empty": "Playlist je prázdný",
"preparing-file": "Připravování souboru…",
"saving": "Ukládání…",
"video-id-not-found": "Video nebylo nalezeno"
}
},
"menu": {
"choose-download-folder": "Vybrat download složku",
"download-playlist": "Stáhnout playlist",
"skip-existing": "Přeskočit existující soubory"
},
"name": "Stahovač",
"templates": {
"button": "Stáhnout"
}
},
"exponential-volume": {
"name": "Exponenciální hlasitost"
},
"last-fm": {
"name": "Last.fm"
},
"lyrics-genius": {
"description": "Přidat lyrics podporu pro většinu písniček"
},
"navigation": {
"name": "Navigace"
},
"no-google-login": {
"description": "Odstranit Google login tlačítka a odkazy z rozhraní",
"name": "Žádné Google přihlášení"
},
"notifications": {
"description": "Display oznámení when a písnička starts hraje (interactive notifications are available on Windows)",
"menu": {
"interactive-settings": {
"label": "Interactive Nastavení",
"submenu": {
"hide-button-text": "Skrýt text tlačítka",
"tray-controls": "Otevřít/Zavřít on tray click"
}
},
"priority": "Priorita Oznámení"
},
"name": "Oznámení"
},
"picture-in-picture": {
"menu": {
"always-on-top": "Vždy na vrchu",
"hotkey": {
"label": "Klávesová zkratka",
"prompt": {
"keybind-options": {
"hotkey": "Klávesová zkratka"
}
}
},
"save-window-position": "Uložit pozici okna",
"save-window-size": "Uložit velikost okna",
"use-native-pip": "Použít browser native PiP"
},
"name": "Obrázek v obrázku",
"templates": {
"button": "Obrázek v obrázku"
}
},
"playback-speed": {
"description": "Posloiuchej rychle, poslouchej pomalu! Adds a slider, který kontroluje rychlost písníčky",
"name": "Playback rychlost",
"templates": {
"button": "Rychlost"
}
},
"precise-volume": {
"menu": {
"global-shortcuts": "Globální klávesové zkratky"
},
"name": "Precise hlasitost",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Snížit hlasitost",
"increase": "Zvýšit hlasitost"
}
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Aktuální kvalita: {{quality}}",
"message": "Vybrat kvalitu videa:",
"title": "Vybrat kvalitu videa"
}
}
}
},
"shortcuts": {
"name": "Zkratky (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Další",
"play-pause": "Přehrát / Pozastavit",
"previous": "Předchozí"
}
}
}
},
"skip-silences": {
"description": "Automaticky přeskakovat tichá místa v písničkách",
"name": "Přeskočit Tichá místa"
},
"taskbar-mediacontrol": {
"description": "Kontrolovat playback z vašeho Windows taskbar"
},
"video-toggle": {
"menu": {
"align": {
"label": "Zarovnání",
"submenu": {
"left": "Vlevo",
"right": "Pravo"
}
},
"mode": {
"label": "Režim"
}
},
"templates": {
"button": "Písnička"
}
}
}
}

594
src/i18n/resources/de.json Normal file
View File

@ -0,0 +1,594 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "Erweiterung {{pluginName}}::{{contextName}} konnte nicht ausgeführt werden",
"executed-at-ms": "Erweiterung {{pluginName}}::{{contextName}} ausgeführt in {{ms}}ms",
"initialize-failed": "Initialisierung der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
"load-all": "Lade alle Erweiterungen",
"load-failed": "Laden der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
"loaded": "Erweiterung \"{{pluginName}}\" geladen",
"unload-failed": "Entladen der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
"unloaded": "Erweiterung \"{{pluginName}}\" entladen"
}
}
},
"language": {
"code": "de",
"local-name": "Deutsch",
"name": "German"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "Laden fertiggestellt. Entwicklerwerkzeuge geöffnet"
},
"i18n": {
"loaded": "i18n geladen"
},
"second-instance": {
"receive-command": "Befehl über Protokoll empfangen: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS-Datei \"{{cssFile}}\" existiert nicht, ignoriere"
},
"unresponsive": {
"details": "Nicht reagierender Fehler!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "Leere Anwendungscache"
},
"window": {
"tried-to-render-offscreen": "Fenster vesucht außerhalb des Bildschirms zu rendern, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "Menü ist versteckt, nutze 'Alt', um es zu zeigen (oder 'Escape' beim Verwenden des In-App-Menüs)",
"message": "Menü verstecken ist aktiviert",
"title": "Menü Verstecken Aktiviert"
},
"need-to-restart": {
"buttons": {
"later": "Später",
"restart-now": "Jetzt neustarten"
},
"detail": "\"{{pluginName}}\"-Erweiterung erfordert einen Neustart, um in Kraft zu treten",
"message": "\"{{pluginName}}\" muss neugestartet werden",
"title": "Neustart Erforderlich"
},
"unresponsive": {
"buttons": {
"quit": "Verlassen",
"relaunch": "Neustarten",
"wait": "Warten"
},
"detail": "Wir entschuldigen uns für die Unannehmlichkeiten! Bitte entscheide, was du tun möchtest:",
"message": "Die Anwendung reagiert nicht",
"title": "Fenster reagiert nicht"
},
"update-available": {
"buttons": {
"disable": "Aktualisierungen deaktivieren",
"download": "Herunterladen",
"ok": "OK"
},
"detail": "Eine neue Version ist verfügbar und kann unter {{downloadLink}} heruntergeladen werden",
"message": "Eine neue Version ist verfügbar",
"title": "Aktualisierung Verfügbar"
}
},
"menu": {
"about": "Über",
"navigation": {
"label": "Navigation",
"submenu": {
"copy-current-url": "Aktuelle URL kopieren",
"go-back": "Zurück gehen",
"go-forward": "Vorwärts gehen",
"quit": "Beenden",
"restart": "Anwendung Neustarten"
}
},
"options": {
"label": "Einstellungen",
"submenu": {
"advanced-options": {
"label": "Erweiterte Einstellungen",
"submenu": {
"auto-reset-app-cache": "Anwendungscache beim Start der Anwendung zurücksetzen",
"disable-hardware-acceleration": "Hardware-Beschleunigung deaktivieren",
"edit-config-json": "config.json ändern",
"override-user-agent": "User-Agent außer Kraft setzen",
"restart-on-config-changes": "Neustarten bei Änderungen der Konfiguration",
"set-proxy": {
"label": "Proxy setzen",
"prompt": {
"label": "Proxy-Adresse eingeben: (leer lassen zum Ausschalten)",
"placeholder": "Beispiel: socks5://127.0.0.1:9999",
"title": "Proxy setzen"
}
},
"toggle-dev-tools": "Entwicklerwerkzeuge umschalten"
}
},
"always-on-top": "Immer im Vordergrund",
"auto-update": "Automatisch Aktualisieren",
"hide-menu": {
"dialog": {
"message": "Menü wird beim nächsten Start versteckt, verwende [Alt], um es zu zeigen (oder Backtick [`], wenn du das In-App-Menü benutzt)",
"title": "Menü Verstecken Aktiviert"
},
"label": "Menü Verstecken"
},
"language": {
"dialog": {
"message": "Sprache wird nach Neustart geändert",
"title": "Sprache Geändert"
},
"label": "Sprache",
"submenu": {
"to-help-translate": "Willst du beim Übersetzen helfen? Klicke hier"
}
},
"resume-on-start": "Letztes Lied weiter abspielen, wenn Anwendung startet",
"single-instance-lock": "Sperren einer einzelnen Instanz",
"start-at-login": "Start beim Einschalten",
"starting-page": {
"label": "Startseite",
"unset": "Ungesetzt"
},
"tray": {
"label": "Tray",
"submenu": {
"disabled": "Deaktiviert",
"enabled-and-hide-app": "Aktiviert und verstecke Anwendung",
"enabled-and-show-app": "Aktiviert und zeige Anwendung",
"play-pause-on-click": "Abspielen/Pausieren durch Klick"
}
},
"visual-tweaks": {
"label": "Visuelle Optimierungen",
"submenu": {
"like-buttons": {
"default": "Standard",
"force-show": "Zeigen erzwungen",
"hide": "Versteckt",
"label": "Gefällt mir-Knopf"
},
"remove-upgrade-button": "Upgrade-Schaltfläche entfernen",
"theme": {
"label": "Thema",
"submenu": {
"import-css-file": "Importiere eigene CSS-Datei",
"no-theme": "Kein Thema"
}
}
}
}
}
},
"plugins": {
"enabled": "Aktiviert",
"label": "Erweiterungen"
},
"view": {
"label": "Ansicht",
"submenu": {
"force-reload": "Neuladen erzwingen",
"reload": "Neu laden",
"reset-zoom": "Tatsächliche Größe",
"toggle-fullscreen": "Vollbild umschalten",
"zoom-in": "Vergrößern",
"zoom-out": "Verkleinern"
}
}
},
"tray": {
"next": "Nächstes",
"play-pause": "Weiter/Pause",
"previous": "Vorheriges",
"quit": "Beenden",
"restart": "Anwendung neu starten",
"show": "Fenster anzeigen"
}
},
"plugins": {
"adblocker": {
"description": "Blockiere jegliche Werbung und Tracker",
"menu": {
"blocker": "Abfangmethode"
},
"name": "Werbeblocker"
},
"album-color-theme": {
"description": "Wendet ein dynamisches Farbthema und visuelle Effekte auf Basis der Farbpalette des Albumcovers an",
"name": "Thema aus Albumfarbe"
},
"ambient-mode": {
"description": "Fügt einen Lichteffekt durch sanftes Abstreifen der Farben des Videos in deinen Bildschirmhintergrund hinzu.",
"menu": {
"blur-amount": {
"label": "Unschärfemenge",
"submenu": {
"pixels": "{{blurAmount}} Pixel"
}
},
"buffer": {
"label": "Puffer",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Durchsichtigkeit",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Qualität",
"submenu": {
"pixels": "{{quality}} Pixel"
}
},
"size": {
"label": "Größe",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Glatter Übergang",
"submenu": {
"during": "Während {{interpolationTime}}s"
}
},
"use-fullscreen": {
"label": "Vollbild nutzen"
}
},
"name": "Ambiente-Modus"
},
"audio-compressor": {
"description": "Kompressor auf Audio anwenden (senkt die Lautstärke der lautesten Teile des Signals und hebt die Lautstärke der leisesten Teile an)",
"name": "Audio-Komprimierer"
},
"blur-nav-bar": {
"description": "Macht Navigationsleiste durchsichtig und unscharf",
"name": "Verschwommene Navigationsleiste"
},
"bypass-age-restrictions": {
"description": "Youtubes Altersbestätigung umgehen",
"name": "Altersbeschränkungen umgehen"
},
"captions-selector": {
"description": "Untertitelwähler für YouTube Music-Audio-Lieder",
"menu": {
"autoload": "Wähle automatisch den zuletzt verwendeten Untertitel",
"disable-captions": "Standartmäßig keine Untertitel"
},
"name": "Untertitelwähler",
"prompt": {
"selector": {
"label": "Aktuelle Untertitelsprache: {{language}}",
"none": "Keine",
"title": "Wähle Untertitelsprache"
}
},
"templates": {
"title": "Untertitelwähler öffnen"
}
},
"compact-sidebar": {
"description": "Seitenleiste immer in den kompakten Modus setzen",
"name": "Kompakte Seitenleiste"
},
"crossfade": {
"description": "Übergang zwischen Liedern",
"menu": {
"advanced": "Erweitert"
},
"name": "Übergang [Beta]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "Einblendezeit (Millisekunden)",
"fade-out-duration": "Ausblendezeit (Millisekunden)",
"fade-scaling": {
"label": "Übergangsskalierung",
"linear": "Linear",
"logarithmic": "Logarithmisch"
},
"seconds-before-end": "Übergang N Sekunden vor dem Ende starten"
},
"title": "Übergangseinstellungen"
}
}
},
"disable-autoplay": {
"description": "Startet Lied im pausierten Modus",
"menu": {
"apply-once": "Nur beim Start der Anwendung anwenden"
},
"name": "Deaktiviere automatisches Abspielen"
},
"discord": {
"backend": {
"already-connected": "Verbindungsaufbau bei aktiver Verbindung versucht",
"connected": "Mit Discord verbunden",
"disconnected": "Verbindung zu Discord getrennt"
},
"description": "Zeige deinen Freunden, was du hörst mit Discords Aktivitätsstatus",
"menu": {
"auto-reconnect": "Automatisch erneut verbinden",
"clear-activity": "Aktivität leeren",
"clear-activity-after-timeout": "Aktivität nach Timeout leeren",
"connected": "Verbunden",
"disconnected": "Getrennt",
"hide-duration-left": "Verbleibende Zeit verstecken",
"hide-github-button": "Knopf mit Link zu GitHub ausblenden",
"play-on-youtube-music": "Auf YouTube Music abspielen",
"set-inactivity-timeout": "Inaktivitätstimeout setzen"
},
"name": "Discords Aktivitätsstatus",
"prompt": {
"set-inactivity-timeout": {
"label": "Inaktivitätstimeout in Sekunden eingeben:",
"title": "Inaktivitätstimeout setzen"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Argh! Entschuldigung, herunterladen fehlgeschlagen…",
"title": "Fehler beim Herunterladen!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{playlistSize}} Lieder)",
"message": "Lade Playlist {{playlistTitle}} herunter",
"title": "Download begonnen"
}
},
"feedback": {
"conversion-progress": "Konvertieren: {{percent}}%",
"converting": "Konvertiere…",
"done": "Abgeschlossen: {{filePath}}",
"download-info": "Lade {{artist}} - {{title}} [{{videoId}} herunter",
"download-progress": "Herunterladen: {{percent}}%",
"downloading": "Lade herunter…",
"downloading-counter": "Lade herunter {{current}}/{{total}}…",
"downloading-playlist": "Lade Playlist \"{{playlistTitle}}\" herunter - {{playlistSize}} Lieder ({{playlistId}})",
"error-while-downloading": "Fehler beim Herunterladen \"{{author}} - {{title}}\": {{error}}",
"folder-already-exists": "Der Ordner {{playlistFolder}} existiert bereits",
"getting-playlist-info": "Hole Playlist-Informationen…",
"loading": "Lade…",
"playlist-has-only-one-song": "Playlist hat nur ein Element, wird direkt heruntergeladen",
"playlist-id-not-found": "Keine Playlist-ID gefunden",
"playlist-is-empty": "Playlist ist leer",
"playlist-is-mix-or-private": "Fehler beim Sammeln der Playlist-Informationen: stelle sicher, dass es keine private oder \"Mixed for you\"-Playlist ist\n\n{{error}}",
"preparing-file": "Bereite Datei vor…",
"saving": "Speichere…",
"trying-to-get-playlist-id": "Versuche Playlist-ID zu bekommen: {{playlistId}}",
"video-id-not-found": "Video nicht gefunden",
"writing-id3": "Schreibe ID3 tags…"
}
},
"description": "Lädt MP3-/Original-Audio direkt von der Schnittstelle herunter",
"menu": {
"choose-download-folder": "Downloadordner wählen",
"download-playlist": "Wiedergabeliste herunterladen",
"presets": "Voreinstellungen",
"skip-existing": "Vorhandene Dateien überspringen"
},
"name": "Downloader",
"renderer": {
"can-not-update-progress": "Fortschritt kann nicht aktualisiert werden"
},
"templates": {
"button": "Herunterladen"
}
},
"exponential-volume": {
"description": "Macht den Lautstärkeregler exponentiell, damit es einfacher ist leise Lautstärken zu wählen.",
"name": "Exponentielle Lautstärke"
},
"in-app-menu": {
"description": "Verleiht den Menüleisten ein schickes, dunkles oder albumfarbenes Aussehen",
"menu": {
"hide-dom-window-controls": "DOM-Fenster-Steuerelemente ausblenden"
},
"name": "In-App Menü"
},
"last-fm": {
"description": "Scrobbling-Unterstützung für Last.fm hinzufügen",
"name": "Last.fm"
},
"lumiastream": {
"description": "Fügt Unterstützung für Lumia Stream hinzu",
"name": "Lumia Stream [Beta]"
},
"lyrics-genius": {
"description": "Für Songtextunterstützung für die meisten Lieder hinzu",
"menu": {
"romanized-lyrics": "Romanisierte Songtexte"
},
"name": "Liedtexte von Genius",
"renderer": {
"fetched-lyrics": "Liedtexte für Genius abgerufen"
}
},
"navigation": {
"description": "Vorwärts/Zurück Navigationspfeile direkt in die Oberfläche integriert - wie in deinem geliebten Browser",
"name": "Navigation"
},
"no-google-login": {
"description": "Googles Anmelden-Knöpfe und -Links von der Oberfläche entfernen",
"name": "Keine Google-Anmeldung"
},
"notifications": {
"description": "Zeige eine Benachrichtigung, wenn ein Lied beginnt zu spielen (interaktive Benachrichtigungen sind unter Windows verfügbar)",
"menu": {
"interactive": "Interaktive Benachrichtigungen",
"interactive-settings": {
"label": "Interaktivitätseinstellungen",
"submenu": {
"hide-button-text": "Text der Knöpfe verstecken",
"refresh-on-play-pause": "Aktualisieren bei Wiedergabe/Pause",
"tray-controls": "Öffnen/Schließen beim Klicken des Tray-Icons"
}
},
"priority": "Benachrichtigungspriorität",
"toast-style": "Toast-Stil",
"unpause-notification": "Benachrichtigungen beim Pausieren anzeigen"
},
"name": "Benachrichtigungen"
},
"picture-in-picture": {
"description": "Erlaubt die App in den Bild-im-Bild-Modus zu wechseln",
"menu": {
"always-on-top": "Immer im Vordergrund",
"hotkey": {
"label": "Tastenkürzel",
"prompt": {
"keybind-options": {
"hotkey": "Tastenkürzel"
},
"label": "Tastenkürzel für Bild-im-Bild wählen",
"title": "Bild-im-Bild Tastenkürzel"
}
},
"save-window-position": "Fensterposition speichern",
"save-window-size": "Fenstergröße speichern",
"use-native-pip": "Browsereigenes PiP verwenden"
},
"name": "Bild im Bild",
"templates": {
"button": "Bild im Bild"
}
},
"playback-speed": {
"description": "Schnell hören, langsam hören! Fügt einen Schieberegler zur Steuerung der Songgeschwindigkeit hinzu",
"name": "Wiedergabegeschwindigkeit",
"templates": {
"button": "Geschwindigkeit"
}
},
"precise-volume": {
"description": "Präzise Steuerung der Lautstärke mit dem Mausrad/Numpad mit einem benutzerdefinierten HUD und benutzerdefinierten Lautstärkestufen",
"menu": {
"arrows-shortcuts": "Lokale Pfeiltasten als Steuerung",
"custom-volume-steps": "Eigene Lautstärkestufen setzen",
"global-shortcuts": "Globale Tastenkürzel"
},
"name": "Genaue Lautstärke",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Lautstärke senken",
"increase": "Lautstärke erhöhen"
},
"label": "Wähle globale Tastenkombinationen für Lautstärke:",
"title": "Globale Lautstärketastenbelegungen"
},
"volume-steps": {
"label": "Wähle Schritte zur Lautstärkehebung/-senkung",
"title": "Lautstärkestufen"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Aktuelle Videoqualität: {{quality}}",
"message": "Wähle Videoqualität:",
"title": "Videoqualität wählen"
}
}
},
"description": "Erlaubt die Videoqualität über einen Knopf auf dem Video",
"name": "Videoqualitätsänderer"
},
"shortcuts": {
"description": "Ermöglicht das Festlegen globaler Hotkeys für die Wiedergabe (Abspielen/Pause/Nächstes/Vorheriges) + Deaktivieren des Medien-OSD durch Überschreiben der Medientasten + Aktivieren von Strg/CMD + F zum Suchen + Aktivieren der Linux mpris-Unterstützung für Medientasten + Angepasste Tastenkürzel für fortgeschrittene Benutzer",
"menu": {
"override-media-keys": "Medienschlüssel überschreiben",
"set-keybinds": "Globale Liedsteuerung setzen"
},
"name": "Abkürzungen (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Nächstes",
"play-pause": "Weiter / Pause",
"previous": "Vorheriges"
},
"label": "Wähle globale Tastenkombinationen für die Liedsteuerung:",
"title": "Globale Tastenkombinationen"
}
}
},
"skip-silences": {
"description": "Automatisch stille Abschnitte in Liedern überspringen",
"name": "Stille überspringen"
},
"sponsorblock": {
"description": "Überspringt automatisch nicht-musikalische Teile wie Intro/Outro oder Teile von Musikvideos, in denen der Song nicht gespielt wird",
"name": "SponsorBlock"
},
"taskbar-mediacontrol": {
"description": "Wiedergabe aus der Windows Taskleiste kontrollieren",
"name": "Mediensteuerung in der Taskleiste"
},
"touchbar": {
"description": "Fügt ein TouchBar-Widget für macOS-Benutzer hinzu",
"name": "TouchBar"
},
"tuna-obs": {
"description": "Integration mit dem OBS-Plugin Tuna",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "Fügt einen Knopf hinzu, um zwischen Video-/Liedmodus zu wechseln. kann auch genutzt werden, um den ganzen Videoabschnitt zu entfernen",
"menu": {
"align": {
"label": "Ausrichtung",
"submenu": {
"left": "Links",
"middle": "Mitte",
"right": "Rechts"
}
},
"force-hide": "Entfernen des Videoabschnitts erzwingen",
"mode": {
"label": "Modus",
"submenu": {
"custom": "Angepasster Schalter",
"disabled": "Deaktiviert",
"native": "Eingebauter Schalter"
}
}
},
"name": "Videoumschalter",
"templates": {
"button": "Lied"
}
},
"visualizer": {
"description": "Fügt einen Visualisierer zum Player hinzu",
"menu": {
"visualizer-type": "Visualisierertyp"
},
"name": "Visualisierer"
}
}
}

242
src/i18n/resources/el.json Normal file
View File

@ -0,0 +1,242 @@
{
"language": {
"code": "el",
"local-name": "Ελληνικά",
"name": "Greek"
},
"main": {
"dialog": {
"hide-menu-enabled": {
"message": "Απόκρυψη μενού είναι ενεργοποιημένο"
},
"update-available": {
"buttons": {
"download": "Download",
"ok": "OK"
}
}
},
"menu": {
"navigation": {
"label": "Navigation"
},
"options": {
"label": "Options",
"submenu": {
"advanced-options": {
"submenu": {
"set-proxy": {
"label": "Set proxy",
"prompt": {
"title": "Set proxy"
}
}
}
},
"auto-update": "Auto Update",
"language": {
"label": "Γλώσσα"
},
"start-at-login": "Start at login",
"tray": {
"label": "Tray",
"submenu": {
"enabled-and-hide-app": "Ενεργοποιημένο και απόκρυψη της εφαρμογής",
"play-pause-on-click": "Play/Pause στο πάτημα"
}
},
"visual-tweaks": {
"submenu": {
"like-buttons": {
"default": "Default"
},
"theme": {
"label": "Theme",
"submenu": {
"no-theme": "No theme"
}
}
}
}
}
},
"plugins": {
"label": "Plugins"
},
"view": {
"label": "View"
}
}
},
"plugins": {
"adblocker": {
"description": "Αποκλεισμός όλων των διαφημίσεων και tracker",
"menu": {
"blocker": "Μέθοδος αποκλεισμού"
},
"name": "Adblocker"
},
"album-color-theme": {
"description": "Εφαρμόζει ένα δυναμικό θέμα και εφέ με βάση τη χρωματική παλέτα του άλμπουμ",
"name": "Album Color Theme"
},
"ambient-mode": {
"description": "Εφαρμόζει ένα εφέ φωτισμού ρίχνοντας απαλά χρώματα από το βίντεο, στο φόντο της οθόνης σας.",
"menu": {
"blur-amount": {
"submenu": {
"pixels": "{{blurAmount}} pixels"
}
},
"buffer": {
"label": "Buffer",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Ποσότητα αδιαφάνειας",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"submenu": {
"pixels": "{{quality}} pixels"
}
},
"size": {
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"submenu": {
"during": "Σε {{interpolationTime}} δεύτερα"
}
}
}
},
"audio-compressor": {
"description": "Συμπίεση ήχου (μειώνει την ένταση των πιο δυνατών τμημάτων του κύματος και αυξάνει την ένταση των πιο μαλακών τμημάτων)"
},
"blur-nav-bar": {
"description": "Κάνει τη γραμμή πλοήγησης διαφανή και θολή"
},
"bypass-age-restrictions": {
"description": "Παράκαμψη της επαλήθευσης ηλικίας του YouTube"
},
"captions-selector": {
"prompt": {
"selector": {
"none": "None"
}
}
},
"compact-sidebar": {
"description": "Να είναι πάντα συμπαγές το sidebar"
},
"crossfade": {
"menu": {
"advanced": "Για προχωρημένους"
},
"prompt": {
"options": {
"multi-input": {
"fade-scaling": {
"linear": "Γραμμική",
"logarithmic": "Λογαριθμική"
}
}
}
}
},
"disable-autoplay": {
"description": "Κάνει τα τραγούδια να είναι αυτόματα σε παύση",
"menu": {
"apply-once": "Εφαρμόζεται μόνο στο πρώτο τραγούδι"
}
},
"discord": {
"description": "Δείξτε στους φίλους σας τι ακούτε με το Rich Presence",
"menu": {
"hide-duration-left": "Απόκρυψη της διάρκειας που απομένει",
"hide-github-button": "Απόκρυψη του συνδέσμου προς GitHub",
"set-inactivity-timeout": "Ορισμός χρονικού ορίου αδράνειας"
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"title": "Error in download!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"message": "Λήψη λίστας αναπαραγωγής {{playlistTitle}}",
"title": "Λήψη ξεκίνησε"
}
},
"feedback": {
"conversion-progress": "Μετατροπή: {{percent}}%",
"download-progress": "Download: {{percent}}%",
"preparing-file": "Προετοιμασία αρχείου…"
}
},
"templates": {
"button": "Download"
}
},
"last-fm": {
"name": "Last.fm"
},
"navigation": {
"name": "Navigation"
},
"no-google-login": {
"name": "No Google Login"
},
"notifications": {
"name": "Notifications"
},
"shortcuts": {
"prompt": {
"keybind": {
"keybind-options": {
"next": "Next"
}
}
}
},
"sponsorblock": {
"name": "SponsorBlock"
},
"touchbar": {
"name": "TouchBar"
},
"tuna-obs": {
"name": "Tuna OBS"
},
"video-toggle": {
"menu": {
"align": {
"submenu": {
"middle": "Middle",
"right": "Right"
}
},
"mode": {
"label": "Mode"
}
},
"templates": {
"button": "Song"
}
}
}
}

594
src/i18n/resources/en.json Normal file
View File

@ -0,0 +1,594 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "Failed to execute plugin {{pluginName}}::{{contextName}}",
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} executed at {{ms}}ms",
"initialize-failed": "Failed to initialize plugin \"{{pluginName}}\"",
"load-all": "Loading all plugins",
"load-failed": "Failed to load plugin \"{{pluginName}}\"",
"loaded": "Plugin \"{{pluginName}}\" loaded",
"unload-failed": "Failed to unload plugin \"{{pluginName}}\"",
"unloaded": "Plugin \"{{pluginName}}\" unloaded"
}
}
},
"language": {
"code": "en",
"local-name": "English",
"name": "English"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "Finished loading. DevTools opened"
},
"i18n": {
"loaded": "i18n loaded"
},
"second-instance": {
"receive-command": "Received command over protocol: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS file \"{{cssFile}}\" does not exist, ignoring"
},
"unresponsive": {
"details": "Unresponsive Error!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "Clearing app cache"
},
"window": {
"tried-to-render-offscreen": "Window tried to render offscreen, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
"message": "Hide Menu is enabled",
"title": "Hide Menu Enabled"
},
"need-to-restart": {
"buttons": {
"later": "Later",
"restart-now": "Restart Now"
},
"detail": "\"{{pluginName}}\" plugin requires a restart to take effect",
"message": "\"{{pluginName}}\" needs to restart",
"title": "Restart Required"
},
"unresponsive": {
"buttons": {
"quit": "Quit",
"relaunch": "Relaunch",
"wait": "Wait"
},
"detail": "We are sorry for the inconvenience! please choose what to do:",
"message": "The Application is Unresponsive",
"title": "Window Unresponsive"
},
"update-available": {
"buttons": {
"disable": "Disable Updates",
"download": "Download",
"ok": "OK"
},
"detail": "A new version is available and can be downloaded at {{downloadLink}}",
"message": "A new version is available",
"title": "Update Available"
}
},
"menu": {
"about": "About",
"navigation": {
"label": "Navigation",
"submenu": {
"copy-current-url": "Copy current URL",
"go-back": "Go back",
"go-forward": "Go forward",
"quit": "Exit",
"restart": "Restart App"
}
},
"options": {
"label": "Options",
"submenu": {
"advanced-options": {
"label": "Advanced options",
"submenu": {
"auto-reset-app-cache": "Reset App cache when app starts",
"disable-hardware-acceleration": "Disable hardware acceleration",
"edit-config-json": "Edit config.json",
"override-user-agent": "Override User-Agent",
"restart-on-config-changes": "Restart on config changes",
"set-proxy": {
"label": "Set proxy",
"prompt": {
"label": "Enter Proxy Address: (leave empty to disable)",
"placeholder": "Example: socks5://127.0.0.1:9999",
"title": "Set proxy"
}
},
"toggle-dev-tools": "Toggle DevTools"
}
},
"always-on-top": "Always on top",
"auto-update": "Auto Update",
"hide-menu": {
"dialog": {
"message": "Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)",
"title": "Hide Menu Enabled"
},
"label": "Hide Menu"
},
"language": {
"dialog": {
"message": "Language will be changed after restart",
"title": "Language Changed"
},
"label": "Language",
"submenu": {
"to-help-translate": "Want to help translate? Click here"
}
},
"resume-on-start": "Resume last song when app starts",
"single-instance-lock": "Single Instance Lock",
"start-at-login": "Start at login",
"starting-page": {
"label": "Starting page",
"unset": "Unset"
},
"tray": {
"label": "Tray",
"submenu": {
"disabled": "Disabled",
"enabled-and-hide-app": "Enabled and hide app",
"enabled-and-show-app": "Enabled and show app",
"play-pause-on-click": "Play/Pause on click"
}
},
"visual-tweaks": {
"label": "Visual Tweaks",
"submenu": {
"like-buttons": {
"default": "Default",
"force-show": "Force show",
"hide": "Hide",
"label": "Like buttons"
},
"remove-upgrade-button": "Remove upgrade button",
"theme": {
"label": "Theme",
"submenu": {
"import-css-file": "Import custom CSS file",
"no-theme": "No theme"
}
}
}
}
}
},
"plugins": {
"enabled": "Enabled",
"label": "Plugins"
},
"view": {
"label": "View",
"submenu": {
"force-reload": "Force Reload",
"reload": "Reload",
"reset-zoom": "Actual Size",
"toggle-fullscreen": "Toggle Full Screen",
"zoom-in": "Zoom In",
"zoom-out": "Zoom Out"
}
}
},
"tray": {
"next": "Next",
"play-pause": "Play/Pause",
"previous": "Previous",
"quit": "Exit",
"restart": "Restart App",
"show": "Show window"
}
},
"plugins": {
"adblocker": {
"description": "Block all ads and tracking out of the box",
"menu": {
"blocker": "Blocker"
},
"name": "Adblocker"
},
"album-color-theme": {
"description": "Applies a dynamic theme and visual effects based on the album color palette",
"name": "Album Color Theme"
},
"ambient-mode": {
"description": "Applies a lighting effect by casting gentle colors from the video, into your screens background.",
"menu": {
"blur-amount": {
"label": "Blur amount",
"submenu": {
"pixels": "{{blurAmount}} pixels"
}
},
"buffer": {
"label": "Buffer",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Opacity",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Quality",
"submenu": {
"pixels": "{{quality}} pixels"
}
},
"size": {
"label": "Size",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Smoothness transition",
"submenu": {
"during": "During {{interpolationTime}}s"
}
},
"use-fullscreen": {
"label": "Using fullscreen"
}
},
"name": "Ambient Mode"
},
"audio-compressor": {
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
"name": "Audio Compressor"
},
"blur-nav-bar": {
"description": "Makes navigation bar transparent and blurry",
"name": "Blur Navigation Bar"
},
"bypass-age-restrictions": {
"description": "Bypass YouTube's age verification",
"name": "Bypass Age Restrictions"
},
"captions-selector": {
"description": "Caption selector for YouTube Music audio tracks",
"menu": {
"autoload": "Automatically select last used caption",
"disable-captions": "No captions by default"
},
"name": "Captions Selector",
"prompt": {
"selector": {
"label": "Current caption language: {{language}}",
"none": "None",
"title": "Select caption language"
}
},
"templates": {
"title": "Open captions selector"
}
},
"compact-sidebar": {
"description": "Always set the sidebar in compact mode",
"name": "Compact Sidebar"
},
"crossfade": {
"description": "Crossfade between songs",
"menu": {
"advanced": "Advanced"
},
"name": "Crossfade [beta]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "Fade in duration (milliseconds)",
"fade-out-duration": "Fade out duration (milliseconds)",
"fade-scaling": {
"label": "Fade scaling",
"linear": "Linear",
"logarithmic": "Logarithmic"
},
"seconds-before-end": "Crossfade N seconds before end"
},
"title": "Crossfade options"
}
}
},
"disable-autoplay": {
"description": "Makes song start in \"paused\" mode",
"menu": {
"apply-once": "Applies only on startup"
},
"name": "Disable Autoplay"
},
"discord": {
"backend": {
"already-connected": "Attempted to connect with active connection",
"connected": "Connected to Discord",
"disconnected": "Disconnected from Discord"
},
"description": "Show your friends what you listen to with Rich Presence",
"menu": {
"auto-reconnect": "Auto reconnect",
"clear-activity": "Clear activity",
"clear-activity-after-timeout": "Clear activity after timeout",
"connected": "Connected",
"disconnected": "Disconnected",
"hide-duration-left": "Hide duration left",
"hide-github-button": "Hide GitHub link Button",
"play-on-youtube-music": "Play on YouTube Music",
"set-inactivity-timeout": "Set inactivity timeout"
},
"name": "Discord Rich Presence",
"prompt": {
"set-inactivity-timeout": {
"label": "Enter inactivity timeout in seconds:",
"title": "Set inactivity timeout"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Argh! Apologies, download failed…",
"title": "Error in download!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{playlistSize}} songs)",
"message": "Downloading Playlist {{playlistTitle}}",
"title": "Download started"
}
},
"feedback": {
"conversion-progress": "Conversion: {{percent}}%",
"converting": "Converting…",
"done": "Done: {{filePath}}",
"download-info": "Downloading {{artist}} - {{title}} [{{videoId}}",
"download-progress": "Download: {{percent}}%",
"downloading": "Downloading…",
"downloading-counter": "Downloading {{current}}/{{total}}…",
"downloading-playlist": "Downloading playlist \"{{playlistTitle}}\" - {{playlistSize}} songs ({{playlistId}})",
"error-while-downloading": "Error downloading \"{{author}} - {{title}}\": {{error}}",
"folder-already-exists": "The folder {{playlistFolder}} already exists",
"getting-playlist-info": "Getting playlist info…",
"loading": "Loading…",
"playlist-has-only-one-song": "Playlist has only one item, downloading it directly",
"playlist-id-not-found": "No playlist ID found",
"playlist-is-empty": "Playlist is empty",
"playlist-is-mix-or-private": "Error getting playlist info: make sure it isn't a private or \"Mixed for you\" playlist\n\n{{error}}",
"preparing-file": "Preparing file…",
"saving": "Saving…",
"trying-to-get-playlist-id": "Trying to get playlist ID: {{playlistId}}",
"video-id-not-found": "Video not found",
"writing-id3": "Writing ID3 tags…"
}
},
"description": "Downloads MP3 / source audio directly from the interface",
"menu": {
"choose-download-folder": "Choose download folder",
"download-playlist": "Download playlist",
"presets": "Presets",
"skip-existing": "Skip existing files"
},
"name": "Downloader",
"renderer": {
"can-not-update-progress": "Cannot update progress"
},
"templates": {
"button": "Download"
}
},
"exponential-volume": {
"description": "Makes the volume slider exponential so it's easier to select lower volumes.",
"name": "Exponential Volume"
},
"in-app-menu": {
"description": "Gives menu-bars a fancy, dark or album-color look",
"menu": {
"hide-dom-window-controls": "Hide DOM window controls"
},
"name": "In-App Menu"
},
"last-fm": {
"description": "Add scrobbling support for Last.fm",
"name": "Last.fm"
},
"lumiastream": {
"description": "Adds Lumia Stream support",
"name": "Lumia Stream [beta]"
},
"lyrics-genius": {
"description": "Adds lyrics support for most songs",
"menu": {
"romanized-lyrics": "Romanized Lyrics"
},
"name": "Lyrics Genius",
"renderer": {
"fetched-lyrics": "Fetched lyrics for Genius"
}
},
"navigation": {
"description": "Next/Back navigation arrows directly integrated in the interface, like in your favorite browser",
"name": "Navigation"
},
"no-google-login": {
"description": "Remove Google login buttons and links from the interface",
"name": "No Google Login"
},
"notifications": {
"description": "Display a notification when a song starts playing (interactive notifications are available on Windows)",
"menu": {
"interactive": "Interactive Notifications",
"interactive-settings": {
"label": "Interactive Settings",
"submenu": {
"hide-button-text": "Hide button text",
"refresh-on-play-pause": "Refresh on Play/Pause",
"tray-controls": "Open/Close on tray click"
}
},
"priority": "Notification Priority",
"toast-style": "Toast style",
"unpause-notification": "Show notification on unpause"
},
"name": "Notifications"
},
"picture-in-picture": {
"description": "Allows to switch the app to picture-in-picture mode",
"menu": {
"always-on-top": "Always on top",
"hotkey": {
"label": "Hotkey",
"prompt": {
"keybind-options": {
"hotkey": "Hotkey"
},
"label": "Choose a hotkey for toggle Picture in Picture",
"title": "Picture in Picture Hotkey"
}
},
"save-window-position": "Save window position",
"save-window-size": "Save window size",
"use-native-pip": "Use browser native PiP"
},
"name": "Picture in Picture",
"templates": {
"button": "Picture in Picture"
}
},
"playback-speed": {
"description": "Listen fast, listen slow! Adds a slider that controls song speed",
"name": "Playback Speed",
"templates": {
"button": "Speed"
}
},
"precise-volume": {
"description": "Control the volume precisely using mousewheel/hotkeys, with a custom HUD and customizable volume steps",
"menu": {
"arrows-shortcuts": "Local Arrow-keys Controls",
"custom-volume-steps": "Set Custom Volume Steps",
"global-shortcuts": "Global Hotkeys"
},
"name": "Precise Volume",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Decrease Volume",
"increase": "Increase Volume"
},
"label": "Choose Global Volume Keybinds:",
"title": "Global Volume Keybinds"
},
"volume-steps": {
"label": "Choose Volume Increase/Decrease Steps",
"title": "Volume Steps"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Current Quality: {{quality}}",
"message": "Choose Video Quality:",
"title": "Choose Video Quality"
}
}
},
"description": "Allows changing the video quality with a button on the video overlay",
"name": "Video Quality Changer"
},
"shortcuts": {
"description": "Allows setting global hotkeys for playback (play/pause/next/previous) + disable media osd by overriding media keys + enable Ctrl/CMD + F to search + enable linux mpris support for mediakeys + custom hotkeys for advanced users",
"menu": {
"override-media-keys": "Override Media Keys",
"set-keybinds": "Set Global Song Controls"
},
"name": "Shortcuts (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Next",
"play-pause": "Play / Pause",
"previous": "Previous"
},
"label": "Choose Global Keybinds for Songs Control:",
"title": "Global Keybinds"
}
}
},
"skip-silences": {
"description": "Automatically skip silences sections in songs",
"name": "Skip Silences"
},
"sponsorblock": {
"description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing",
"name": "SponsorBlock"
},
"taskbar-mediacontrol": {
"description": "Control playback from your Windows taskbar",
"name": "Taskbar Media Control"
},
"touchbar": {
"description": "Adds a TouchBar widget for macOS users",
"name": "TouchBar"
},
"tuna-obs": {
"description": "Integration with OBS's plugin Tuna",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "Adds a button to switch between Video/Song mode. can also optionally remove the whole video tab",
"menu": {
"align": {
"label": "Alignment",
"submenu": {
"left": "Left",
"middle": "Middle",
"right": "Right"
}
},
"force-hide": "Force remove video tab",
"mode": {
"label": "Mode",
"submenu": {
"custom": "Custom toggle",
"disabled": "Disabled",
"native": "Native toggle"
}
}
},
"name": "Video Toggle",
"templates": {
"button": "Song"
}
},
"visualizer": {
"description": "Adds a visualizer to the player",
"menu": {
"visualizer-type": "Visualizer Type"
},
"name": "Visualizer"
}
}
}

112
src/i18n/resources/fr.json Normal file
View File

@ -0,0 +1,112 @@
{
"language": {
"code": "fr",
"local-name": "Français",
"name": "French"
},
"main": {
"console": {
"theme": {
"css-file-not-found": "Le fichier de CSS \"{{cssFile}}\" n'existe pas, ignorer"
}
},
"dialog": {
"update-available": {
"buttons": {
"download": "Sauvegarder"
}
}
},
"menu": {
"options": {
"label": "Paramètres",
"submenu": {
"advanced-options": {
"submenu": {
"edit-config-json": "Modifier config.json",
"set-proxy": {
"prompt": {
"placeholder": "Exemple: socks5://127.0.0.1:9999"
}
}
}
},
"language": {
"label": "Langue"
},
"visual-tweaks": {
"submenu": {
"like-buttons": {
"hide": "Cacher"
}
}
}
}
}
}
},
"plugins": {
"ambient-mode": {
"menu": {
"buffer": {
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"submenu": {
"percent": "{{opacity}}%"
}
},
"size": {
"submenu": {
"percent": "{{size}}%"
}
}
}
},
"captions-selector": {
"prompt": {
"selector": {
"none": "Aucun"
}
}
},
"crossfade": {
"prompt": {
"options": {
"multi-input": {
"fade-scaling": {
"linear": "Linéaire",
"logarithmic": "Logarithmique"
}
}
}
}
},
"downloader": {
"backend": {
"dialog": {
"start-download-playlist": {
"detail": "({{playlistSize}} chansons)",
"title": "Téléchargement a commencé"
}
},
"feedback": {
"download-progress": "Télécharger: {{percent}}%",
"downloading": "Télécharge…",
"downloading-counter": "Télécharge {{current}}/{{total}}…",
"preparing-file": "Péparer des fichier…",
"saving": "Sauvegarde…"
}
},
"name": "Téléchargeur",
"templates": {
"button": "Télécharger"
}
},
"last-fm": {
"name": "Last.fm"
}
}
}

594
src/i18n/resources/ja.json Normal file
View File

@ -0,0 +1,594 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "プラグイン・{{pluginName}}:{{contextName}}を実行できませんでした",
"executed-at-ms": "プラグイン {{pluginName}}::{{contextName}} は {{ms}}ms で実行されました",
"initialize-failed": "プラグイン \"{{pluginName}}\" の初期化に失敗",
"load-all": "すべてのプラグインをロード中",
"load-failed": "プラグイン”{{pluginName}}”のロードが失敗しました",
"loaded": "プラグイン”{{pluginName}}”ロード完了",
"unload-failed": "プラグインのアンロードに失敗 \"{{pluginName}}\"",
"unloaded": "プラグイン {{pluginName}} がアンロードされました"
}
}
},
"language": {
"code": "ja",
"local-name": "日本語",
"name": "Japanese"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "ロード完了。デベロッパーツールが開きました"
},
"i18n": {
"loaded": "翻訳ロード完了"
},
"second-instance": {
"receive-command": "プロトコルより命令を受けました:”{{command}}”"
},
"theme": {
"css-file-not-found": "CSSファイル”{{cssFile}}”が存在しません。無視します"
},
"unresponsive": {
"details": "応答なしエラー!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "アプリのキャッシュを削除中"
},
"window": {
"tried-to-render-offscreen": "ウィンドウは画面外をレンダリングしようとしました, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "メニューは非表示です。'Alt'で表示します。(アプリ内メニューには'Escape'を使用します)",
"message": "メニューの非表示が有効です",
"title": "メニューの非表示が有効"
},
"need-to-restart": {
"buttons": {
"later": "あとで",
"restart-now": "今すぐ再起動する"
},
"detail": "プラグイン ”{{pluginName}}” を有効にするには再起動が必要です",
"message": "”{{pluginName}}”は再起動が必要です",
"title": "再起動が必要"
},
"unresponsive": {
"buttons": {
"quit": "閉じる",
"relaunch": "再起動",
"wait": "待つ"
},
"detail": "ご不便をおかけして申し訳ございません! 何をするか選んでください:",
"message": "アプリケーションは応答しません",
"title": "ウィンドウが応答しません"
},
"update-available": {
"buttons": {
"disable": "更新を無効化",
"download": "ダウンロード",
"ok": "OK"
},
"detail": "新しいバージョンが利用可能です。{{downloadLink}} からダウンロードできます",
"message": "新しいバージョンが利用可能",
"title": "アップデートが利用可能"
}
},
"menu": {
"about": "このアプリについて",
"navigation": {
"label": "移動",
"submenu": {
"copy-current-url": "現在のURLをコピー",
"go-back": "戻る",
"go-forward": "進む",
"quit": "終了",
"restart": "アプリを再起動"
}
},
"options": {
"label": "設定",
"submenu": {
"advanced-options": {
"label": "高度な設定",
"submenu": {
"auto-reset-app-cache": "アプリの開始時にキャッシュをリセット",
"disable-hardware-acceleration": "ハードウェアアクセラレーションの無効化",
"edit-config-json": "config.json を編集する",
"override-user-agent": "ユーザーエージェントの上書き",
"restart-on-config-changes": "設定変更時に再起動",
"set-proxy": {
"label": "プロキシ",
"prompt": {
"label": "プロキシのアドレスを入力: (空にすると無効化)",
"placeholder": "例: socks5://127.0.0.1:9999",
"title": "プロキシ"
}
},
"toggle-dev-tools": "DevToolsの切り替え"
}
},
"always-on-top": "常に最前面に表示",
"auto-update": "自動アップデート",
"hide-menu": {
"dialog": {
"message": "メニューは次の起動から非表示になります。表示するには[Alt]キーを使用します (in-app-menuを使用している場合は[`]を使用します)",
"title": "メニューの非表示が有効"
},
"label": "メニューの非表示"
},
"language": {
"dialog": {
"message": "言語は再起動後に変更されます",
"title": "言語が変更されました"
},
"label": "言語設定",
"submenu": {
"to-help-translate": "翻訳をサポートしたいですか?こちらをクリック"
}
},
"resume-on-start": "起動時に最後の曲を再開する",
"single-instance-lock": "単一インスタンスロック",
"start-at-login": "windowsのログイン時に起動",
"starting-page": {
"label": "スターティングページ",
"unset": "未設定"
},
"tray": {
"label": "トレイアイコン",
"submenu": {
"disabled": "無効",
"enabled-and-hide-app": "有効 + アプリを非表示",
"enabled-and-show-app": "有効 + アプリを表示",
"play-pause-on-click": "クリックで再生/一時停止"
}
},
"visual-tweaks": {
"label": "見た目の微調整",
"submenu": {
"like-buttons": {
"default": "デフォルト",
"force-show": "強制的に表示",
"hide": "非表示",
"label": "いいねボタン"
},
"remove-upgrade-button": "アップグレードボタンを削除",
"theme": {
"label": "テーマ",
"submenu": {
"import-css-file": "CSSファイルをインポート",
"no-theme": "テーマなし"
}
}
}
}
}
},
"plugins": {
"enabled": "有効",
"label": "プラグイン"
},
"view": {
"label": "表示",
"submenu": {
"force-reload": "強制再読み込み",
"reload": "再読み込み",
"reset-zoom": "実際のサイズ",
"toggle-fullscreen": "全画面表示を切り替え",
"zoom-in": "拡大",
"zoom-out": "縮小"
}
}
},
"tray": {
"next": "次の曲",
"play-pause": "再生/一時停止",
"previous": "前の曲",
"quit": "終了",
"restart": "アプリを再起動",
"show": "ウィンドウを表示"
}
},
"plugins": {
"adblocker": {
"description": "すべての広告とトラッカーをブロックj",
"menu": {
"blocker": "ブロッカー"
},
"name": "Adblocker"
},
"album-color-theme": {
"description": "アルバムカバーの色をベースにして動的テーマと視覚効果を適用します",
"name": "アルバムカラーベースのテーマ"
},
"ambient-mode": {
"description": "動画の間接照明を画面背景に投射します。",
"menu": {
"blur-amount": {
"label": "ぼかしの強さ",
"submenu": {
"pixels": "{{blurAmount}} ピクセル"
}
},
"buffer": {
"label": "バッファリング",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "不透明度",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "品質",
"submenu": {
"pixels": "{{quality}} ピクセル"
}
},
"size": {
"label": "大きさ",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "スムーズな切り替えり",
"submenu": {
"during": "{{interpolationTime}}秒間切り替えり"
}
},
"use-fullscreen": {
"label": "全体画面モード使用"
}
},
"name": "アンビエント モード"
},
"audio-compressor": {
"description": "オーディオにコンプレッサーを適用します(信号での一番大きい部分の音量を下げ、小さい部分の音量を上げる)",
"name": "オーディオコンプレッサー"
},
"blur-nav-bar": {
"description": "ナビゲーションバーを透明かつぼやけにします",
"name": "ナビゲーションバーの曇り効果"
},
"bypass-age-restrictions": {
"description": "ユーチューブの年齢制限を迂回します",
"name": "年齢制限迂回"
},
"captions-selector": {
"description": "YouTube Musicトラック用字幕選択機",
"menu": {
"autoload": "最後の字幕を自動に選択",
"disable-captions": "デフォルトで字幕を無効化"
},
"name": "字幕選択機",
"prompt": {
"selector": {
"label": "選択した字幕言語: {{language}}",
"none": "なし",
"title": "字幕の言語を選択"
}
},
"templates": {
"title": "字幕選択機を開く"
}
},
"compact-sidebar": {
"description": "サイドバーを常にコンパクトモードに設定します",
"name": "コンパクトなサイドバー"
},
"crossfade": {
"description": "曲の間にクロスフェード効果を適用します",
"menu": {
"advanced": "詳細設定"
},
"name": "クロスフェード[ベータ]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "フェードイン持続時間(ミリ秒)",
"fade-out-duration": "フェードアウト持続時間(ミリ秒)",
"fade-scaling": {
"label": "フェードスケーリング",
"linear": "線形",
"logarithmic": "対数スケール"
},
"seconds-before-end": "終了N秒前にクロスフェードを適用"
},
"title": "クロスフェード設定"
}
}
},
"disable-autoplay": {
"description": "曲を「一時停止」モードで始めさせます",
"menu": {
"apply-once": "起動時のみ適用"
},
"name": "自動再生を無効化"
},
"discord": {
"backend": {
"already-connected": "すでに有効になっている接続に接続を試みました",
"connected": "ディスコードに接続中",
"disconnected": "Discordから切断されました"
},
"description": "アクティビティ ステータスで、あなたが聴いている曲を友達に見せましょう",
"menu": {
"auto-reconnect": "自動再接続",
"clear-activity": "アクティビティの削除",
"clear-activity-after-timeout": "タイムアウト発生時にアクティビティを削除",
"connected": "接続済み",
"disconnected": "切断済み",
"hide-duration-left": "残りの再生時間を隠す",
"hide-github-button": "GitHubリンクボタンを隠す",
"play-on-youtube-music": "YouTube Musicで再生",
"set-inactivity-timeout": "タイムアウト時間を設定"
},
"name": "Discordアクティビティステータス",
"prompt": {
"set-inactivity-timeout": {
"label": "非アクティブ時のタイムアウトを秒単位で入力:",
"title": "非アクティブタイムアウト"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "ダウンロード失敗!ごめんね…",
"title": "ダウンロード中にエラーが発生しました!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "{{playlistSize}}曲)",
"message": "プレイリスト {{playlistTitle}} をダウンロード中",
"title": "ダウンロード開始"
}
},
"feedback": {
"conversion-progress": "変換:{{percent}}",
"converting": "変換中…",
"done": "完了:{{filePath}}",
"download-info": "{{artist}}ー{{title}} {{videoId}} をダウンロード中",
"download-progress": "ダウンロード:{{percent}}",
"downloading": "ダウンロード中…",
"downloading-counter": "ダウンロード中:{{current}}/{{total}}…",
"downloading-playlist": "プレイリストをダウンロード中:\"{{playlistTitle}}\" -{{playlistSize}}曲({{playlistId}}",
"error-while-downloading": "\"{{author}}ー{{title}}\"ダウンロード中にエラー発生:{{error}}",
"folder-already-exists": "フォルダー {{playlistFolder}}が既に存在します",
"getting-playlist-info": "プレイリスト情報を取得中…",
"loading": "ロード中…",
"playlist-has-only-one-song": "プレイリストに1曲しかありません。直接ダウンロードします",
"playlist-id-not-found": "プレイリストIDが見つかりません",
"playlist-is-empty": "プレイリストは空です",
"playlist-is-mix-or-private": "プレイリスト情報をダウンロード中にエラーが発生しました: プレイリストが非公開ではないこと、\"Mixed for you\"ではないことを確認してください\n\n{{error}}",
"preparing-file": "ファイルを準備中…",
"saving": "保存中…",
"trying-to-get-playlist-id": "プレイリストIDを取得中{{playlistId}}",
"video-id-not-found": "動画が見つかりません",
"writing-id3": "ID3タグ作成中…"
}
},
"description": "UIから直にMP3・ソースオーディオをダウンロードします",
"menu": {
"choose-download-folder": "ダウンロードフォルダ",
"download-playlist": "プレイリストをダウンロード",
"presets": "プリセット",
"skip-existing": "存在するファイルをスキップ"
},
"name": "ダウンローダー",
"renderer": {
"can-not-update-progress": "進捗を更新できません"
},
"templates": {
"button": "ダウンロード"
}
},
"exponential-volume": {
"description": "音量スライダを指数関数的にさせ、低い音量に設定しやすくなります。",
"name": "指数音量"
},
"in-app-menu": {
"description": "メニューバーをファンシー、ダーク、またはアルバムカラーの外観にする",
"menu": {
"hide-dom-window-controls": "DOMウィンドウコントロールを隠す"
},
"name": "アプリ内メニュー"
},
"last-fm": {
"description": "Last.fmのscrobblingサポートを追加",
"name": "Last.fm"
},
"lumiastream": {
"description": "Lumia Streamのサポートを追加",
"name": "Lumia Stream [ベータ]"
},
"lyrics-genius": {
"description": "より広い範囲の曲に歌詞を付けます",
"menu": {
"romanized-lyrics": "ローマ字歌詞"
},
"name": "Genius 歌詞",
"renderer": {
"fetched-lyrics": "Geniusから歌詞取得完了"
}
},
"navigation": {
"description": "ブラウザの戻る・進むボタンのようにUIからコントロールできるボタン",
"name": "ナビゲーション"
},
"no-google-login": {
"description": "インターフェースからGoogleのログインボタンとリンクを削除",
"name": "No Google Login"
},
"notifications": {
"description": "曲の再生開始時に通知を表示する(Windowsではインタラクティブ通知が利用可能)",
"menu": {
"interactive": "インタラクティブ通知",
"interactive-settings": {
"label": "インタラクティブ通知 設定",
"submenu": {
"hide-button-text": "ボタンのテキストを非表示",
"refresh-on-play-pause": "再生/一時停止時に更新",
"tray-controls": "トレイアイコンのクリック時に開閉"
}
},
"priority": "通知の優先度",
"toast-style": "トーストのスタイル",
"unpause-notification": "再生再開時に通知を表示"
},
"name": "通知"
},
"picture-in-picture": {
"description": "アプリでピクチャ・イン・ピクチャを切り替えられるようになります",
"menu": {
"always-on-top": "常に最前面に表示",
"hotkey": {
"label": "ホットキー",
"prompt": {
"keybind-options": {
"hotkey": "ホットキー"
},
"label": "ピクチャインピクチャを切り替えるためのホットキーを選択",
"title": "ピクチャインピクチャのホットキー"
}
},
"save-window-position": "ウィンドウの位置を保存",
"save-window-size": "ウィンドウのサイズを保存",
"use-native-pip": "ブラウザ標準のPiPを使用"
},
"name": "ピクチャインピクチャ",
"templates": {
"button": "ピクチャインピクチャ"
}
},
"playback-speed": {
"description": "速く聴く、遅く聴く!曲のスピードをコントロールするスライダーを追加",
"name": "再生速度",
"templates": {
"button": "速度"
}
},
"precise-volume": {
"description": "カスタムHUDとカスタマイズ可能な音量ステップで、マウスホイール/ホットキーを使って音量を正確にコントロールします",
"menu": {
"arrows-shortcuts": "ローカル矢印キー操作",
"custom-volume-steps": "カスタム音量ステップを設定",
"global-shortcuts": "グローバル ホットキー"
},
"name": "正確な音量",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "音量を下げる",
"increase": "音量を上げる"
},
"label": "グローバルキーバインドを選択:",
"title": "グローバル 音量 キーバインド"
},
"volume-steps": {
"label": "音量の増減ステップを選択",
"title": "音量ステップ"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "現在の品質: {{quality}}",
"message": "ビデオ品質を選択:",
"title": "ビデオ品質を選択:"
}
}
},
"description": "ビデオオーバーレイのボタンを使用してビデオ品質を変更できるようにします",
"name": "ビデオ品質チェンジャー"
},
"shortcuts": {
"description": "再生用のグローバル ホットキー (再生/一時停止/次/前) の設定 + メディア キーをオーバーライドしてメディア OSD を無効にする + Ctrl/CMD + F による検索を有効にする + メディアキーの Linux mpris サポートを有効にする + 上級ユーザー向けのカスタム ホットキー を可能にします",
"menu": {
"override-media-keys": "メディアキーを上書き",
"set-keybinds": "グローバルソングコントロールを設定する"
},
"name": "ショートカット (および MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "次",
"play-pause": "再生/一時停止",
"previous": "前の"
},
"label": "曲コントロールのグローバルキーバインドを選択:",
"title": "グローバル キーバインド"
}
}
},
"skip-silences": {
"description": "曲の無音区間を自動でスキップ",
"name": "無音区間をスキップ"
},
"sponsorblock": {
"description": "イントロ/アウトロなどの音楽以外の部分や、曲が再生されていないミュージック ビデオの部分を自動的にスキップします",
"name": "SponsorBlock"
},
"taskbar-mediacontrol": {
"description": "Windowsタスクバーから再生をコントロール",
"name": "Taskbar Media Control"
},
"touchbar": {
"description": "masOSユーザー向けにTouchBarウィジェットを追加",
"name": "TouchBar"
},
"tuna-obs": {
"description": "OBSのプラグインTunaの統合",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "ビデオ/ソングモードを切り替えるボタンを追加します。オプションでビデオタブ全体を削除することもできます",
"menu": {
"align": {
"label": "位置",
"submenu": {
"left": "左",
"middle": "中央",
"right": "右"
}
},
"force-hide": "強制的にビデオタブを削除",
"mode": {
"label": "モード",
"submenu": {
"custom": "カスタム切り替え",
"disabled": "無効",
"native": "標準の切り替え"
}
}
},
"name": "動画の切り替え",
"templates": {
"button": "曲"
}
},
"visualizer": {
"description": "視覚効果(ビジュアライザー)をプレイヤーに追加します",
"menu": {
"visualizer-type": "ビジュアライザーの種類"
},
"name": "視覚効果"
}
}
}

594
src/i18n/resources/ko.json Normal file
View File

@ -0,0 +1,594 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "확장 {{pluginName}}::{{contextName}}을(를) 실행하지 못했습니다",
"executed-at-ms": "확장 {{pluginName}}::{{contextName}}이 {{ms}}ms 만에 실행됨",
"initialize-failed": "확장 \"{{pluginName}}\"을(를) 초기화하지 못했습니다",
"load-all": "모든 확장 로드 중",
"load-failed": "확장 \"{{pluginName}}\"을(를) 로드하지 못했습니다",
"loaded": "확장 \"{{pluginName}}\" 로드됨",
"unload-failed": "확장 \"{{pluginName}}\"을(를) 언로드하지 못했습니다",
"unloaded": "확장 \"{{pluginName}}\" 언로드 됨"
}
}
},
"language": {
"code": "ko",
"local-name": "한국어",
"name": "Korean"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "로드가 완료되었습니다. 개발자 도구가 열렸습니다"
},
"i18n": {
"loaded": "국제화 로드됨"
},
"second-instance": {
"receive-command": "프로토콜을 통해 명령을 받았습니다: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS 파일 \"{{cssFile}}\"이(가) 존재하지 않습니다. 무시합니다"
},
"unresponsive": {
"details": "응답 없음 오류!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "앱 캐시 지우기"
},
"window": {
"tried-to-render-offscreen": "창이 오프스크린 렌더링을 시도했습니다. windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "'Alt' 키를 눌러 숨겨진 메뉴를 표시할 수 있습니다 (인앱 메뉴를 사용하는 경우 'Esc' 키를 사용)",
"message": "메뉴 숨기기가 활성화되어 있습니다",
"title": "메뉴 숨기기 활성화됨"
},
"need-to-restart": {
"buttons": {
"later": "나중에 하기",
"restart-now": "지금 재시작하기"
},
"detail": "\"{{pluginName}}\" 확장을 적용하려면 재시작해야 합니다",
"message": "\"{{pluginName}}\"은(는) 재시작이 필요합니다",
"title": "재시작 필요"
},
"unresponsive": {
"buttons": {
"quit": "종료",
"relaunch": "재시작",
"wait": "기다리기"
},
"detail": "불편을 드려 죄송합니다! 방법을 선택해 주세요:",
"message": "애플리케이션이 응답하지 않습니다",
"title": "창이 응답하지 않음"
},
"update-available": {
"buttons": {
"disable": "업데이트 비활성화",
"download": "다운로드",
"ok": "확인"
},
"detail": "새 버전이 출시되었습니다. {{downloadLink}}에서 다운로드할 수 있습니다",
"message": "새 버전을 사용할 수 있습니다",
"title": "업데이트 사용 가능"
}
},
"menu": {
"about": "정보",
"navigation": {
"label": "탐색",
"submenu": {
"copy-current-url": "현재 URL 복사",
"go-back": "뒤로 가기",
"go-forward": "앞으로 가기",
"quit": "종료",
"restart": "앱 재시작"
}
},
"options": {
"label": "설정",
"submenu": {
"advanced-options": {
"label": "고급 설정",
"submenu": {
"auto-reset-app-cache": "앱 시작 시 앱 캐시 초기화",
"disable-hardware-acceleration": "하드웨어 가속 비활성화",
"edit-config-json": "config.json 편집",
"override-user-agent": "User-Agent 재정의",
"restart-on-config-changes": "설정 변경 시 재시작",
"set-proxy": {
"label": "프록시 설정",
"prompt": {
"label": "프록시 주소를 입력하세요: (비워두면 비활성화됨)",
"placeholder": "예제: socks5://127.0.0.1:9999",
"title": "프록시 설정"
}
},
"toggle-dev-tools": "DevTools 열기"
}
},
"always-on-top": "항상 최상단에 표시",
"auto-update": "자동 업데이트",
"hide-menu": {
"dialog": {
"message": "다음 실행 시 메뉴가 숨겨집니다. 표시하려면 [Alt] 키를 사용하세요 (인앱 메뉴를 사용하는 경우 백틱 [`] 키를 사용하세요)",
"title": "메뉴 숨기기 활성화됨"
},
"label": "메뉴 숨기기"
},
"language": {
"dialog": {
"message": "재시작 후 언어가 변경됩니다",
"title": "언어 변경됨"
},
"label": "언어",
"submenu": {
"to-help-translate": "번역을 돕고 싶으신가요? 여기를 누르세요"
}
},
"resume-on-start": "앱 시작 시 마지막 곡 다시 듣기",
"single-instance-lock": "단일 인스턴스 잠금",
"start-at-login": "로그온 시 자동 실행",
"starting-page": {
"label": "시작 페이지",
"unset": "지정 안 됨"
},
"tray": {
"label": "트레이",
"submenu": {
"disabled": "비활성화",
"enabled-and-hide-app": "활성화 및 앱 숨기기",
"enabled-and-show-app": "활성화 및 앱 표시",
"play-pause-on-click": "클릭 시 재생/일시 정지"
}
},
"visual-tweaks": {
"label": "시각적 변경",
"submenu": {
"like-buttons": {
"default": "기본",
"force-show": "강제로 표시",
"hide": "숨기기",
"label": "좋아요 버튼"
},
"remove-upgrade-button": "업그레이드 버튼 제거",
"theme": {
"label": "테마",
"submenu": {
"import-css-file": "사용자 정의 CSS 파일 가져오기",
"no-theme": "테마 없음"
}
}
}
}
}
},
"plugins": {
"enabled": "활성화",
"label": "확장"
},
"view": {
"label": "보기",
"submenu": {
"force-reload": "강제 새로고침",
"reload": "새로고침",
"reset-zoom": "원래 크기",
"toggle-fullscreen": "전체 화면 전환",
"zoom-in": "확대",
"zoom-out": "축소"
}
}
},
"tray": {
"next": "다음",
"play-pause": "재생/일시정지",
"previous": "이전",
"quit": "종료",
"restart": "앱 재시작",
"show": "창 표시"
}
},
"plugins": {
"adblocker": {
"description": "모든 광고와 트래커를 즉시 차단합니다",
"menu": {
"blocker": "애드블록 타입"
},
"name": "애드블록"
},
"album-color-theme": {
"description": "앨범 색상 팔레트를 기반으로 동적 테마 및 시각 효과를 적용합니다",
"name": "앨범 컬러 기반 테마"
},
"ambient-mode": {
"description": "영상의 간접 조명을 화면 배경에 투사합니다.",
"menu": {
"blur-amount": {
"label": "흐림 효과 강도",
"submenu": {
"pixels": "{{blurAmount}} 픽셀"
}
},
"buffer": {
"label": "버퍼링",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "불투명도",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "품질",
"submenu": {
"pixels": "{{quality}} 픽셀"
}
},
"size": {
"label": "크기",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "부드러운 전환",
"submenu": {
"during": "{{interpolationTime}}초 동안 전환"
}
},
"use-fullscreen": {
"label": "전체 화면 모드 사용"
}
},
"name": "앰비언트 모드"
},
"audio-compressor": {
"description": "오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)",
"name": "오디오 컴프레서"
},
"blur-nav-bar": {
"description": "탐색 바를 투명하고 흐릿하게 만듭니다",
"name": "탐색 바 흐림 효과"
},
"bypass-age-restrictions": {
"description": "유튜브의 나이 제한을 우회합니다",
"name": "나이 제한 우회"
},
"captions-selector": {
"description": "YouTube Music 트랙용 자막 선택기입니다",
"menu": {
"autoload": "마지막으로 사용한 자막을 자동으로 선택",
"disable-captions": "기본 자막 제거"
},
"name": "자막 선택기",
"prompt": {
"selector": {
"label": "현재 선택된 언어: {{language}}",
"none": "없음",
"title": "자막 언어 선택"
}
},
"templates": {
"title": "자막 선택기 열기"
}
},
"compact-sidebar": {
"description": "사이드바를 항상 컴팩트 모드로 설정합니다",
"name": "컴팩트 사이드바"
},
"crossfade": {
"description": "노래 사이에 크로스페이드 효과를 적용합니다",
"menu": {
"advanced": "고급 설정"
},
"name": "크로스페이드 [베타]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "페이드인 지속 시간 (밀리초)",
"fade-out-duration": "페이드아웃 지속 시간 (밀리초)",
"fade-scaling": {
"label": "페이드 스케일링",
"linear": "선형",
"logarithmic": "로그스케일"
},
"seconds-before-end": "종료되기 N초 전에 크로스페이드 적용"
},
"title": "크로스페이드 설정"
}
}
},
"disable-autoplay": {
"description": "노래를 '일시 정지' 모드로 시작하게 합니다",
"menu": {
"apply-once": "첫 시작 시에만 적용"
},
"name": "자동 재생 해제"
},
"discord": {
"backend": {
"already-connected": "활성화 된 연결에 연결을 시도했습니다",
"connected": "디스코드에 연결됨",
"disconnected": "디스코드에서 연결이 끊김"
},
"description": "활동 상태를 사용하여 친구들에게 내가 듣는 음악을 보여주세요",
"menu": {
"auto-reconnect": "자동 연결",
"clear-activity": "활동 제거",
"clear-activity-after-timeout": "시간 초과 시 활동 제거",
"connected": "연결 됨",
"disconnected": "연결 해제 됨",
"hide-duration-left": "남은 재생 시간 숨기기",
"hide-github-button": "GitHub 링크 버튼 숨기기",
"play-on-youtube-music": "유튜브 뮤직에서 재생",
"set-inactivity-timeout": "비활성 시간 제한 설정"
},
"name": "디스코드 활동 상태",
"prompt": {
"set-inactivity-timeout": {
"label": "비활성 시간 제한을 초 단위로 입력하세요:",
"title": "비활성 시간 제한 설정"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "확인"
},
"message": "죄송합니다. 다운로드가 실패했습니다…",
"title": "다운로드 중 오류 발생!"
},
"start-download-playlist": {
"buttons": {
"ok": "확인"
},
"detail": "({{playlistSize}} 곡)",
"message": "재생목록 {{playlistTitle}} 다운로드 중",
"title": "다운로드 시작됨"
}
},
"feedback": {
"conversion-progress": "변환: {{percent}}%",
"converting": "변환 중…",
"done": "완료: {{filePath}}",
"download-info": "{{artist}} - {{title}} [{{videoId}} 다운로드 중",
"download-progress": "다운로드: {{percent}}%",
"downloading": "다운로드 중…",
"downloading-counter": "다운로드 중 {{current}}/{{total}}…",
"downloading-playlist": "재생목록 다운로드 중: \"{{playlistTitle}}\" - {{playlistSize}} 곡 ({{playlistId}})",
"error-while-downloading": "\"{{author}} - {{title}}\" 다운로드 중 오류 발생: {{error}}",
"folder-already-exists": "{{playlistFolder}} 폴더가 이미 존재합니다",
"getting-playlist-info": "재생목록 정보를 가져오는 중…",
"loading": "로딩 중…",
"playlist-has-only-one-song": "재생목록에 한 항목만 존재합니다. 직접 다운로드합니다",
"playlist-id-not-found": "재생목록 ID를 찾을 수 없습니다",
"playlist-is-empty": "재생목록이 비어있습니다",
"playlist-is-mix-or-private": "재생목록 정보 가져오는 중 오류 발생: 비공개 재생목록 또는 '유튜브 Mix' 재생목록이 아닌지 확인하세요\n\n{{error}}",
"preparing-file": "파일 준비 중…",
"saving": "저장 중…",
"trying-to-get-playlist-id": "재생목록 ID를 가져오는 중: {{playlistId}}",
"video-id-not-found": "영상을 찾을 수 없습니다",
"writing-id3": "ID3 태그 작성 중…"
}
},
"description": "UI에서 직접 MP3/소스 오디오를 다운로드하세요",
"menu": {
"choose-download-folder": "다운로드 폴더 선택",
"download-playlist": "재생목록 다운로드",
"presets": "프리셋",
"skip-existing": "이미 존재하는 파일 넘기기"
},
"name": "다운로더",
"renderer": {
"can-not-update-progress": "진행 상황을 업데이트 할 수 없음"
},
"templates": {
"button": "다운로드"
}
},
"exponential-volume": {
"description": "음량 슬라이더를 지수적으로 만들어 더 낮은 음량을 쉽게 선택할 수 있도록 합니다.",
"name": "지수 음량"
},
"in-app-menu": {
"description": "메뉴 표시줄을 더 멋지게, 그리고 다크 또는 앨범의 색상으로 만듭니다",
"menu": {
"hide-dom-window-controls": "DOM 윈도우 컨트롤 숨기기"
},
"name": "인앱 메뉴"
},
"last-fm": {
"description": "Last.fm에 대한 스크러블 지원을 추가합니다",
"name": "Last.fm"
},
"lumiastream": {
"description": "Lumia Stream 지원을 추가합니다",
"name": "Lumia Stream [베타]"
},
"lyrics-genius": {
"description": "더 많은 곡에 대해 가사 지원을 추가합니다",
"menu": {
"romanized-lyrics": "가사 로마자화"
},
"name": "Genius 가사",
"renderer": {
"fetched-lyrics": "Genius에서 가사 불러옴"
}
},
"navigation": {
"description": "브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표",
"name": "탐색"
},
"no-google-login": {
"description": "UI에서 Google 로그인 버튼 및 링크 제거하기",
"name": "Google 로그인 제거"
},
"notifications": {
"description": "노래 재생이 시작되면 알림을 표시 (Windows에서는 대화형 알림 사용 가능)",
"menu": {
"interactive": "대화형 알림",
"interactive-settings": {
"label": "대화형 알림 설정",
"submenu": {
"hide-button-text": "버튼 텍스트 숨기기",
"refresh-on-play-pause": "재생/일시정지 시 새로고침",
"tray-controls": "트레이 클릭 시 열기/닫기"
}
},
"priority": "알림 우선순위",
"toast-style": "토스트 스타일",
"unpause-notification": "일시정지 시 알림 표시"
},
"name": "알림"
},
"picture-in-picture": {
"description": "앱을 PiP 모드로 전환할 수 있게 허용합니다",
"menu": {
"always-on-top": "항상 맨 위에 표시",
"hotkey": {
"label": "단축키",
"prompt": {
"keybind-options": {
"hotkey": "단축키"
},
"label": "PiP를 전환하기 위한 단축키를 선택하세요",
"title": "PiP 단축키"
}
},
"save-window-position": "창 위치 저장",
"save-window-size": "창 크기 저장",
"use-native-pip": "브라우저 내장 PiP 사용"
},
"name": "PiP",
"templates": {
"button": "PiP"
}
},
"playback-speed": {
"description": "빨리 듣거나, 천천히 들어보세요! 노래 속도를 제어하는 슬라이더를 추가합니다",
"name": "재생 속도",
"templates": {
"button": "배속"
}
},
"precise-volume": {
"description": "사용자 지정 HUD와 사용자 지정 음량 단계로 마우스 휠/단축키를 사용하여 음량을 정확하게 제어하세요",
"menu": {
"arrows-shortcuts": "로컬 화살표 키 컨트롤",
"custom-volume-steps": "사용자 지정 음량 단계 설정",
"global-shortcuts": "전역 단축키"
},
"name": "정확한 음량",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "음량 감소",
"increase": "음량 증가"
},
"label": "전역 음량 키를 지정하세요:",
"title": "전역 음량 키 지정"
},
"volume-steps": {
"label": "음량 증가/감소 단계를 선택하세요",
"title": "음량 단계"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "현재 품질: {{quality}}",
"message": "영상 품질 선택:",
"title": "영상 품질 선택"
}
}
},
"description": "영상 오버레이의 버튼으로 영상 품질을 변경할 수 있습니다",
"name": "영상 품질 체인저"
},
"shortcuts": {
"description": "재생을 위한 전역 단축키 설정 허용 (재생/일시 정지/다음/이전) + 미디어 키를 재정의하여 미디어 OSD 비활성화 + Ctrl/CMD + F 검색 활성화 + 미디어 키에 대한 리눅스 MPRIS 지원 활성화 + 고급 사용자를 위한 사용자 지정 단축키 지원",
"menu": {
"override-media-keys": "미디어 키 재정의",
"set-keybinds": "전역 노래 제어 설정"
},
"name": "단축키 (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "다음",
"play-pause": "재생 / 일시정지",
"previous": "이전"
},
"label": "노래 조작을 위한 전역 키를 선택하세요:",
"title": "전역 키 지정"
}
}
},
"skip-silences": {
"description": "노래의 무음 부분을 자동으로 건너뜁니다",
"name": "무음 건너뛰기"
},
"sponsorblock": {
"description": "인트로/아웃트로와 같은 음악이 아닌 부분이나, 노래가 재생되지 않는 뮤직 비디오의 일부를 자동으로 건너뜁니다",
"name": "SponsorBlock"
},
"taskbar-mediacontrol": {
"description": "Windows 작업 표시줄에서 재생을 제어하세요",
"name": "작업표시줄 미디어 컨트롤"
},
"touchbar": {
"description": "macOS 사용자를 위한 TouchBar 위젯을 추가합니다",
"name": "TouchBar"
},
"tuna-obs": {
"description": "OBS의 확장인 Tuna와의 통합을 활성화합니다",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "영상/노래 모드를 전환하는 버튼을 추가합니다. 선택적으로 전체 영상 탭을 제거할 수도 있습니다",
"menu": {
"align": {
"label": "정렬",
"submenu": {
"left": "왼쪽",
"middle": "가운데",
"right": "오른쪽"
}
},
"force-hide": "영상 탭 강제 제거",
"mode": {
"label": "모드",
"submenu": {
"custom": "사용자 지정 전환",
"disabled": "비활성화",
"native": "기본 토글"
}
}
},
"name": "영상 전환",
"templates": {
"button": "노래"
}
},
"visualizer": {
"description": "플레이어에 시각화 도구 추가",
"menu": {
"visualizer-type": "비주얼라이저 타입"
},
"name": "비주얼라이저"
}
}
}

205
src/i18n/resources/nb.json Normal file
View File

@ -0,0 +1,205 @@
{
"language": {
"code": "nb_NO",
"local-name": "Norsk bokmål",
"name": "Norwegian Bokmål"
},
"main": {
"menu": {
"options": {
"label": "Alternativer",
"submenu": {
"hide-menu": {
"label": "Skjul meny"
},
"tray": {
"label": "Systemkurv",
"submenu": {
"disabled": "Avskrudd"
}
},
"visual-tweaks": {
"submenu": {
"theme": {
"label": "Drakt",
"submenu": {
"import-css-file": "Importer egendefinert CSS-fil",
"no-theme": "Ingen"
}
}
}
}
}
},
"plugins": {
"label": "Programtillegg"
}
}
},
"plugins": {
"adblocker": {
"description": "Stenger ute reklame og sporing",
"menu": {
"blocker": "Blokkering"
},
"name": "Reklameblokkering"
},
"album-color-theme": {
"description": "Ifører dynamisk drakt og visuelle effekter basert på albumsfargepaletten",
"name": "Albumsfargedrakt"
},
"ambient-mode": {
"menu": {
"blur-amount": {
"label": "Tilsløringsmengde",
"submenu": {
"pixels": "{{blurAmount}} piksler"
}
},
"opacity": {
"label": "Dekkevne",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Kvalitet",
"submenu": {
"pixels": "{{quality}} piksler"
}
},
"size": {
"label": "Størrelse",
"submenu": {
"percent": "{{size}}%"
}
}
}
},
"captions-selector": {
"prompt": {
"selector": {
"label": "Nåværende tekstingsspråk: {{language}}",
"none": "Ingen",
"title": "Velg tekstingsspråk"
}
},
"templates": {
"title": "Åpne undertekstvelger"
}
},
"crossfade": {
"menu": {
"advanced": "Avansert"
},
"prompt": {
"options": {
"multi-input": {
"fade-scaling": {
"linear": "Lineær",
"logarithmic": "Logaritmisk"
}
}
}
}
},
"disable-autoplay": {
"name": "Skru av autospilling"
},
"discord": {
"backend": {
"disconnected": "Frakoblet fra Discord"
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
}
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"title": "Nedlasting startet"
}
},
"feedback": {
"conversion-progress": "Konvertering: {{percent}}%",
"converting": "Konverterer …",
"done": "Ferdig: {{filePath}}",
"downloading": "Laster ned …",
"loading": "Laster inn …",
"playlist-is-empty": "Tom spilleliste",
"preparing-file": "Forbereder fil …",
"saving": "Lagrer …"
}
},
"menu": {
"choose-download-folder": "Velg nedlastningsmappe",
"presets": "Forhåndsinnstillinger",
"skip-existing": "Hopp over eksisterende filer"
},
"name": "Nedlaster",
"templates": {
"button": "Last ned"
}
},
"last-fm": {
"name": "Last.fm"
},
"notifications": {
"name": "Merknader"
},
"picture-in-picture": {
"menu": {
"save-window-position": "Lagre vindusposisjon"
}
},
"playback-speed": {
"name": "Avspillingshastighet",
"templates": {
"button": "Hastighet"
}
},
"precise-volume": {
"name": "Presis lydstyrkejustering"
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Nåværende kvalitet: {{quality}}",
"message": "Velg videokvalitet:",
"title": "Velg videokvalitet"
}
}
}
},
"skip-silences": {
"name": "Hopp over pauser"
},
"sponsorblock": {
"name": "SponsorBlock"
},
"video-toggle": {
"menu": {
"align": {
"submenu": {
"left": "Venstre",
"middle": "Midten",
"right": "Høyre"
}
},
"mode": {
"label": "Modus"
}
},
"templates": {
"button": "Spor"
}
}
}
}

220
src/i18n/resources/ru.json Normal file
View File

@ -0,0 +1,220 @@
{
"language": {
"code": "ru",
"local-name": "Русский",
"name": "Russian"
},
"main": {
"dialog": {
"update-available": {
"buttons": {
"download": "Download",
"ok": "OK"
}
}
},
"menu": {
"navigation": {
"label": "Navigation"
},
"options": {
"label": "Options",
"submenu": {
"advanced-options": {
"submenu": {
"set-proxy": {
"label": "Set proxy",
"prompt": {
"title": "Set proxy"
}
}
}
},
"auto-update": "Auto Update",
"start-at-login": "Start at login",
"tray": {
"label": "Tray"
},
"visual-tweaks": {
"submenu": {
"like-buttons": {
"default": "Default"
},
"theme": {
"label": "Theme",
"submenu": {
"no-theme": "No theme"
}
}
}
}
}
},
"plugins": {
"label": "Plugins"
},
"view": {
"label": "View"
}
}
},
"plugins": {
"adblocker": {
"description": "Блокируйте всю рекламу и трекинг сразу после установки",
"menu": {
"blocker": "Блокировщик"
},
"name": "Блокировщик рекламы"
},
"album-color-theme": {
"description": "Применяет динамическую тему и визуальные эффекты на основе цветовой палитры альбома",
"name": "Цветовая тема альбома"
},
"ambient-mode": {
"description": "Применяет световой эффект, отбрасывая мягкие цвета из видео на задний фон вашего экрана.",
"menu": {
"blur-amount": {
"label": "Степень размытия",
"submenu": {
"pixels": "{{blurAmount}} пикселей"
}
},
"buffer": {
"label": "Буфер",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Прозрачность",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Качество",
"submenu": {
"pixels": "{{quality}} пикселей"
}
},
"size": {
"label": "Размер",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Плавный переход",
"submenu": {
"during": "В течение {{interpolationTime}}s"
}
},
"use-fullscreen": {
"label": "Использовать полноэкранный режим"
}
},
"name": "Режим Ambient"
},
"audio-compressor": {
"description": "Применяет компрессию к аудио (уменьшает громкость самых громких частей сигнала и повышает громкость самых тихих частей)",
"name": "Аудио компрессор"
},
"blur-nav-bar": {
"description": "Делает панель навигации прозрачной и размытой",
"name": "Размытие панели навигации"
},
"bypass-age-restrictions": {
"description": "Обход проверки возраста на YouTube",
"name": "Обход возрастных ограничений"
},
"captions-selector": {
"description": "Выбор субтитров для аудиотреков в YouTube Music",
"name": "Выбор субтитров",
"prompt": {
"selector": {
"none": "None"
}
}
},
"crossfade": {
"prompt": {
"options": {
"multi-input": {
"fade-scaling": {
"linear": "Linear"
}
}
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"title": "Error in download!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
}
}
},
"feedback": {
"download-progress": "Download: {{percent}}%"
}
},
"templates": {
"button": "Download"
}
},
"last-fm": {
"name": "Last.fm"
},
"navigation": {
"name": "Navigation"
},
"no-google-login": {
"name": "No Google Login"
},
"notifications": {
"name": "Notifications"
},
"shortcuts": {
"prompt": {
"keybind": {
"keybind-options": {
"next": "Next"
}
}
}
},
"sponsorblock": {
"name": "SponsorBlock"
},
"touchbar": {
"name": "TouchBar"
},
"tuna-obs": {
"name": "Tuna OBS"
},
"video-toggle": {
"menu": {
"align": {
"submenu": {
"middle": "Middle",
"right": "Right"
}
},
"mode": {
"label": "Mode"
}
},
"templates": {
"button": "Song"
}
}
}
}

View File

@ -0,0 +1,145 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "插件 {{pluginName}} 無法被執行::{{contextName}}",
"executed-at-ms": "插件 {{pluginName}} ::{{contextName}} 用了 {{ms}} ms 來執行",
"initialize-failed": "初始化插件 \"{{pluginName}}\" 失敗",
"load-all": "載入所有插件",
"load-failed": "載入插件 \"{{pluginName}}\" 失敗",
"loaded": "插件 \"{{pluginName}}\" 已被載入",
"unload-failed": "解除安裝插件 \"{{pluginName}}\" 失敗",
"unloaded": "插件 \"{{pluginName}}\" 已被解除安裝"
}
}
},
"language": {
"code": "zh-TW",
"local-name": "正體字",
"name": "Traditional Chinese"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "載入完成。開發者工具已開啟"
},
"i18n": {
"loaded": "i18n 已載入"
},
"second-instance": {
"receive-command": "使用協定來接收指令: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS 檔案 \"{{cssFile}}\" 不存在,已忽略"
},
"unresponsive": {
"details": "無回應錯誤!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "清理程式的快取資料"
},
"window": {
"tried-to-render-offscreen": "視窗正嘗試在螢幕外渲染,視窗大小 = {{windowSize}},螢幕大小 = {{displaySize}},位置 = {{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "選單已隱藏,使用 'Alt' 鍵來重新顯示(或是使用空白鍵來使用程式內選單)",
"message": "隱藏選單已經啟用",
"title": "隱藏選單已啟用"
},
"need-to-restart": {
"buttons": {
"later": "稍後",
"restart-now": "立即重啟"
},
"detail": "插件 \"{{pluginName}}\" 需要程式重新啟動之後才會生效",
"message": "\"{{pluginName}}\" 需要重新啟動",
"title": "需要重新啟動"
},
"unresponsive": {
"buttons": {
"quit": "離開",
"relaunch": "重新啟動",
"wait": "等一下"
},
"detail": "造成不便我們深表歉意!請選擇動作:",
"message": "應用程式無回應",
"title": "視窗無回應"
},
"update-available": {
"buttons": {
"disable": "關閉更新",
"download": "下載",
"ok": "OK"
},
"detail": "新的版本已經推出,你可以到 {{downloadLink}} 下載",
"message": "有新版本可用",
"title": "有可用的更新"
}
},
"menu": {
"about": "關於",
"navigation": {
"label": "導覽列",
"submenu": {
"copy-current-url": "複製目前的網址",
"go-back": "回到上一頁",
"go-forward": "回到下一頁",
"quit": "退出",
"restart": "重啟程式"
}
},
"options": {
"label": "選項",
"submenu": {
"advanced-options": {
"label": "進階選項",
"submenu": {
"auto-reset-app-cache": "當程式啟動時重置應用程式快取",
"disable-hardware-acceleration": "關閉硬體加速",
"edit-config-json": "編輯 config.json",
"override-user-agent": "複寫用戶代理",
"restart-on-config-changes": "重新啟動來更改配置",
"set-proxy": {
"label": "設定代理伺服器",
"prompt": {
"label": "輸入代理伺服器位置:(留空以停用本設定)",
"placeholder": "示例: socks5://127.0.0.1:9999",
"title": "設定代理伺服器"
}
},
"toggle-dev-tools": "切換開發者工具"
}
},
"always-on-top": "永遠顯示在最上層",
"auto-update": "自動更新",
"hide-menu": {
"label": "隱藏選單"
},
"language": {
"dialog": {
"message": "語言會在下一次重啟應用時變更",
"title": "語言已變更"
},
"label": "語言",
"submenu": {
"to-help-translate": "想要協助翻譯?點擊這裡"
}
},
"resume-on-start": "繼續上次關閉應用前的音樂",
"start-at-login": "開機時啟動",
"starting-page": {
"label": "啟動頁面",
"unset": "未設置"
},
"tray": {
"submenu": {
"disabled": "已停用"
}
}
}
}
}
}
}

14
src/index.html Normal file
View File

@ -0,0 +1,14 @@
<!-- WARNING:
This file only exists for the build system to work properly.
Any changes done here won't be reflected in the final build.
The actual loading of `renderer.ts` is done in `src/index.ts`
within the `createMainWindow` function.
Archived link for reference:
https://github.com/th-ch/youtube-music/blob/a3104fda4b0d58b076d0c737111636a66e468acc/src/index.ts#L407-L443
-->
<script type="module" src="./renderer.ts"></script>

View File

@ -1,49 +1,60 @@
import path from 'node:path'; import path from 'node:path';
import url from 'node:url';
import fs from 'node:fs';
import process from 'node:process';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron'; import {
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request'; BrowserWindow,
app,
screen,
globalShortcut,
session,
shell,
dialog,
ipcMain,
} from 'electron';
import enhanceWebRequest, {
BetterSession,
} from '@jellybrick/electron-better-web-request';
import is from 'electron-is'; import is from 'electron-is';
import unhandled from 'electron-unhandled'; import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import electronDebug from 'electron-debug'; import electronDebug from 'electron-debug';
import { parse } from 'node-html-parser';
import { deepmerge } from 'deepmerge-ts';
import { deepEqual } from 'fast-equals';
import config from './config'; import { allPlugins, mainPlugins } from 'virtual:plugins';
import { refreshMenu, setApplicationMenu } from './menu';
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils';
import { isTesting } from './utils/testing';
import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
import adblocker from './plugins/adblocker/back'; import { languageResources } from 'virtual:i18n';
import albumColorTheme from './plugins/album-color-theme/back';
import ambientMode from './plugins/ambient-mode/back';
import blurNavigationBar from './plugins/blur-nav-bar/back';
import captionsSelector from './plugins/captions-selector/back';
import crossfade from './plugins/crossfade/back';
import discord from './plugins/discord/back';
import downloader from './plugins/downloader/back';
import httpApi from './plugins/http-api/back';
import inAppMenu from './plugins/in-app-menu/back';
import lastFm from './plugins/last-fm/back';
import lumiaStream from './plugins/lumiastream/back';
import lyricsGenius from './plugins/lyrics-genius/back';
import navigation from './plugins/navigation/back';
import noGoogleLogin from './plugins/no-google-login/back';
import notifications from './plugins/notifications/back';
import pictureInPicture, { setOptions as pipSetOptions } from './plugins/picture-in-picture/back';
import preciseVolume from './plugins/precise-volume/back';
import qualityChanger from './plugins/quality-changer/back';
import shortcuts from './plugins/shortcuts/back';
import sponsorBlock from './plugins/sponsorblock/back';
import taskbarMediaControl from './plugins/taskbar-mediacontrol/back';
import touchbar from './plugins/touchbar/back';
import tunaObs from './plugins/tuna-obs/back';
import videoToggle from './plugins/video-toggle/back';
import visualizer from './plugins/visualizer/back';
import youtubeMusicCSS from './youtube-music.css'; import config from '@/config';
import { refreshMenu, setApplicationMenu } from '@/menu';
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
import { isTesting } from '@/utils/testing';
import { setUpTray } from '@/tray';
import { setupSongInfo } from '@/providers/song-info';
import { restart, setupAppControls } from '@/providers/app-controls';
import {
APP_PROTOCOL,
handleProtocol,
setupProtocolHandler,
} from '@/providers/protocol-handler';
import youtubeMusicCSS from '@/youtube-music.css?inline';
import {
forceLoadMainPlugin,
forceUnloadMainPlugin,
getAllLoadedMainPlugins,
loadAllMainPlugins,
} from '@/loader/main';
import { LoggerPrefix } from '@/utils';
import { loadI18n, setLanguage, t } from '@/i18n';
import type { PluginConfig } from '@/types/plugins';
// Catch errors and log them // Catch errors and log them
unhandled({ unhandled({
@ -65,7 +76,10 @@ if (!gotTheLock) {
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt) // SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
// OverlayScrollbar: Required for overlay scrollbars // OverlayScrollbar: Required for overlay scrollbars
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer'); app.commandLine.appendSwitch(
'enable-features',
'OverlayScrollbar,SharedArrayBuffer',
);
if (config.get('options.disableHardwareAcceleration')) { if (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) { if (is.dev()) {
console.log('Disabling hardware acceleration'); console.log('Disabling hardware acceleration');
@ -101,50 +115,116 @@ function onClosed() {
mainWindow = null; mainWindow = null;
} }
const mainPlugins = {
'adblocker': adblocker,
'album-color-theme': albumColorTheme,
'ambient-mode': ambientMode,
'blur-nav-bar': blurNavigationBar,
'captions-selector': captionsSelector,
'crossfade': crossfade,
'discord': discord,
'downloader': downloader,
'http-api': httpApi,
'in-app-menu': inAppMenu,
'last-fm': lastFm,
'lumiastream': lumiaStream,
'lyrics-genius': lyricsGenius,
'navigation': navigation,
'no-google-login': noGoogleLogin,
'notifications': notifications,
'picture-in-picture': pictureInPicture,
'precise-volume': preciseVolume,
'quality-changer': qualityChanger,
'shortcuts': shortcuts,
'sponsorblock': sponsorBlock,
'taskbar-mediacontrol': undefined as typeof taskbarMediaControl | undefined,
'touchbar': undefined as typeof touchbar | undefined,
'tuna-obs': tunaObs,
'video-toggle': videoToggle,
'visualizer': visualizer,
};
export const mainPluginNames = Object.keys(mainPlugins);
if (is.windows()) {
mainPlugins['taskbar-mediacontrol'] = taskbarMediaControl;
delete mainPlugins['touchbar'];
} else if (is.macOS()) {
mainPlugins['touchbar'] = touchbar;
delete mainPlugins['taskbar-mediacontrol'];
} else {
delete mainPlugins['touchbar'];
delete mainPlugins['taskbar-mediacontrol'];
}
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
async function loadPlugins(win: BrowserWindow) { const initHook = (win: BrowserWindow) => {
ipcMain.handle(
'get-config',
(_, id: string) =>
deepmerge(
allPlugins[id].config ?? { enabled: false },
config.get(`plugins.${id}`) ?? {},
) as PluginConfig,
);
ipcMain.handle('set-config', (_, name: string, obj: object) =>
config.setPartial(`plugins.${name}`, obj, allPlugins[name].config),
);
config.watch((newValue, oldValue) => {
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
string,
unknown
>;
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
string,
unknown
>;
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
if (!isEqual) {
const oldConfig = oldPluginConfigList[id] as PluginConfig;
const config = deepmerge(
allPlugins[id].config ?? { enabled: false },
newPluginConfig ?? {},
) as PluginConfig;
if (config.enabled !== oldConfig?.enabled) {
if (config.enabled) {
win.webContents.send('plugin:enable', id);
ipcMain.emit('plugin:enable', id);
forceLoadMainPlugin(id, win);
} else {
win.webContents.send('plugin:unload', id);
ipcMain.emit('plugin:unload', id);
forceUnloadMainPlugin(id, win);
}
if (allPlugins[id]?.restartNeeded) {
showNeedToRestartDialog(id);
}
}
const mainPlugin = getAllLoadedMainPlugins()[id];
if (mainPlugin) {
if (config.enabled && typeof mainPlugin.backend !== 'function') {
mainPlugin.backend?.onConfigChange?.call(
mainPlugin.backend,
config,
);
}
}
win.webContents.send('config-changed', id, config);
}
});
});
};
const showNeedToRestartDialog = (id: string) => {
const plugin = allPlugins[id];
const dialogOptions: Electron.MessageBoxOptions = {
type: 'info',
buttons: [
t('main.dialog.need-to-restart.buttons.restart-now'),
t('main.dialog.need-to-restart.buttons.later'),
],
title: t('main.dialog.need-to-restart.title'),
message: t('main.dialog.need-to-restart.message', {
pluginName: plugin?.name?.() ?? id,
}),
detail: t('main.dialog.need-to-restart.detail', {
pluginName: plugin?.name?.() ?? id,
}),
defaultId: 0,
cancelId: 1,
};
let dialogPromise: Promise<Electron.MessageBoxReturnValue>;
if (mainWindow) {
dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions);
} else {
dialogPromise = dialog.showMessageBox(dialogOptions);
}
dialogPromise.then((dialogOutput) => {
switch (dialogOutput.response) {
case 0: {
restart();
break;
}
// Ignore
default: {
break;
}
}
});
};
function initTheme(win: BrowserWindow) {
injectCSS(win.webContents, youtubeMusicCSS); injectCSS(win.webContents, youtubeMusicCSS);
// Load user CSS // Load user CSS
const themes: string[] = config.get('options.themes'); const themes: string[] = config.get('options.themes');
@ -156,7 +236,10 @@ async function loadPlugins(win: BrowserWindow) {
injectCSSAsFile(win.webContents, cssFile); injectCSSAsFile(win.webContents, cssFile);
}, },
() => { () => {
console.warn(`CSS file "${cssFile}" does not exist, ignoring`); console.warn(
LoggerPrefix,
t('main.console.theme.css-file-not-found', { cssFile }),
);
}, },
); );
} }
@ -164,24 +247,10 @@ async function loadPlugins(win: BrowserWindow) {
win.webContents.once('did-finish-load', () => { win.webContents.once('did-finish-load', () => {
if (is.dev()) { if (is.dev()) {
console.log('did finish load'); console.debug(LoggerPrefix, t('main.console.did-finish-load.dev-tools'));
win.webContents.openDevTools(); win.webContents.openDevTools();
} }
}); });
for (const [plugin, options] of config.plugins.getEnabled()) {
try {
if (Object.hasOwn(mainPlugins, plugin)) {
console.log('Loaded plugin - ' + plugin);
const handler = mainPlugins[plugin as keyof typeof mainPlugins];
if (handler) {
await handler(win, options as never);
}
}
} catch (e) {
console.error(`Failed to load plugin "${plugin}"`, e);
}
}
} }
async function createMainWindow() { async function createMainWindow() {
@ -190,6 +259,12 @@ async function createMainWindow() {
const windowPosition: Electron.Point = config.get('window-position'); const windowPosition: Electron.Point = config.get('window-position');
const useInlineMenu = config.plugins.isEnabled('in-app-menu'); const useInlineMenu = config.plugins.isEnabled('in-app-menu');
const defaultTitleBarOverlayOptions: Electron.TitleBarOverlayOptions = {
color: '#00000000',
symbolColor: '#ffffff',
height: 32,
};
const win = new BrowserWindow({ const win = new BrowserWindow({
icon, icon,
width: windowSize.width, width: windowSize.width,
@ -197,53 +272,62 @@ async function createMainWindow() {
backgroundColor: '#000', backgroundColor: '#000',
show: false, show: false,
webPreferences: { webPreferences: {
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm contextIsolation: true,
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126 preload: path.join(__dirname, '..', 'preload', 'preload.js'),
contextIsolation: false,
preload: path.join(__dirname, 'preload.js'),
nodeIntegrationInSubFrames: true,
...(isTesting() ...(isTesting()
? undefined ? undefined
: { : {
// Sandbox is only enabled in tests for now // Sandbox is only enabled in tests for now
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
sandbox: false, sandbox: false,
}), }),
}, },
frame: !is.macOS() && !useInlineMenu, frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: { titleBarOverlay: defaultTitleBarOverlayOptions,
color: '#00000000',
symbolColor: '#ffffff',
height: 36,
},
titleBarStyle: useInlineMenu titleBarStyle: useInlineMenu
? 'hidden' ? 'hidden'
: (is.macOS() : is.macOS()
? 'hiddenInset' ? 'hiddenInset'
: 'default'), : 'default',
autoHideMenuBar: config.get('options.hideMenu'), autoHideMenuBar: config.get('options.hideMenu'),
}); });
await loadPlugins(win); initHook(win);
initTheme(win);
await loadAllMainPlugins(win);
if (windowPosition) { if (windowPosition) {
const { x: windowX, y: windowY } = windowPosition; const { x: windowX, y: windowY } = windowPosition;
const winSize = win.getSize(); const winSize = win.getSize();
const displaySize const display = screen.getDisplayNearestPoint(windowPosition);
= screen.getDisplayNearestPoint(windowPosition).bounds; const scaleFactor = display.scaleFactor;
const scaledWidth = Math.floor(windowSize.width / scaleFactor);
const scaledHeight = Math.floor(windowSize.height / scaleFactor);
const scaledX = windowX;
const scaledY = windowY;
if ( if (
windowX + winSize[0] < displaySize.x - 8 scaledX + scaledWidth < display.bounds.x - 8 ||
|| windowX - winSize[0] > displaySize.x + displaySize.width scaledX - scaledWidth > display.bounds.x + display.bounds.width ||
|| windowY < displaySize.y - 8 scaledY < display.bounds.y - 8 ||
|| windowY > displaySize.y + displaySize.height scaledY > display.bounds.y + display.bounds.height
) { ) {
// Window is offscreen // Window is offscreen
if (is.dev()) { if (is.dev()) {
console.log( console.warn(
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`, LoggerPrefix,
t('main.console.window.tried-to-render-offscreen', {
winSize: String(winSize),
displaySize: String(display.bounds),
windowPosition: String(windowPosition),
}),
); );
} }
} else { } else {
win.setPosition(windowX, windowY); win.setSize(scaledWidth, scaledHeight);
win.setPosition(scaledX, scaledY);
} }
} }
@ -260,40 +344,22 @@ async function createMainWindow() {
: config.defaultConfig.url; : config.defaultConfig.url;
win.on('closed', onClosed); win.on('closed', onClosed);
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
// eslint-disable-next-line @typescript-eslint/no-var-requires
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
: () => {};
win.on('move', () => { win.on('move', () => {
if (win.isMaximized()) { if (win.isMaximized()) {
return; return;
} }
const position = win.getPosition(); const [x, y] = win.getPosition();
const isPiPEnabled: boolean lateSave('window-position', { x, y });
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled) {
lateSave('window-position', { x: position[0], y: position[1] });
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
lateSave('pip-position', position, setPiPOptions);
}
}); });
let winWasMaximized: boolean; let winWasMaximized: boolean;
win.on('resize', () => { win.on('resize', () => {
const windowSize = win.getSize(); const [width, height] = win.getSize();
const isMaximized = win.isMaximized(); const isMaximized = win.isMaximized();
const isPiPEnabled if (winWasMaximized !== isMaximized) {
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
winWasMaximized = isMaximized; winWasMaximized = isMaximized;
config.set('window-maximized', isMaximized); config.set('window-maximized', isMaximized);
} }
@ -302,19 +368,19 @@ async function createMainWindow() {
return; return;
} }
if (!isPiPEnabled) { lateSave('window-size', {
lateSave('window-size', { width,
width: windowSize[0], height,
height: windowSize[1], });
});
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
lateSave('pip-size', windowSize, setPiPOptions);
}
}); });
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {}; const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) { function lateSave(
key: string,
value: unknown,
fn: (key: string, value: unknown) => void = config.set,
) {
if (savedTimeouts[key]) { if (savedTimeouts[key]) {
clearTimeout(savedTimeouts[key]); clearTimeout(savedTimeouts[key]);
} }
@ -325,7 +391,7 @@ async function createMainWindow() {
}, 600); }, 600);
} }
app.on('render-process-gone', (event, webContents, details) => { app.on('render-process-gone', (_event, _webContents, details) => {
showUnresponsiveDialog(win, details); showUnresponsiveDialog(win, details);
}); });
@ -337,32 +403,88 @@ async function createMainWindow() {
removeContentSecurityPolicy(); removeContentSecurityPolicy();
win.webContents.on('dom-ready', async () => {
if (useInlineMenu && !is.linux()) {
win.setTitleBarOverlay({
...defaultTitleBarOverlayOptions,
height: Math.floor(
defaultTitleBarOverlayOptions.height! *
win.webContents.getZoomFactor(),
),
});
}
// Inject index.html file as string using insertAdjacentHTML
// In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync
if (is.dev() && process.env.ELECTRON_RENDERER_URL) {
// HACK: to make vite work with electron renderer (supports hot reload)
await win.webContents.executeJavaScript(`
console.log('Loading vite from dev server');
const viteScript = document.createElement('script');
viteScript.type = 'module';
viteScript.src = '${process.env.ELECTRON_RENDERER_URL}/@vite/client';
const rendererScript = document.createElement('script');
rendererScript.type = 'module';
rendererScript.src = '${process.env.ELECTRON_RENDERER_URL}/renderer.ts';
document.body.appendChild(viteScript);
document.body.appendChild(rendererScript);
0
`);
} else {
const rendererPath = path.join(__dirname, '..', 'renderer');
const indexHTML = parse(
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
);
const scriptSrc = indexHTML.querySelector('script')!;
const scriptPath = path.join(
rendererPath,
scriptSrc.getAttribute('src')!,
);
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
await win.webContents.executeJavaScriptInIsolatedWorld(
0,
[
{
code: scriptString + ';0',
url: url.pathToFileURL(scriptPath).toString(),
},
],
true,
);
}
});
win.webContents.loadURL(urlToLoad); win.webContents.loadURL(urlToLoad);
return win; return win;
} }
app.once('browser-window-created', (event, win) => { app.once('browser-window-created', (_event, win) => {
if (config.get('options.overrideUserAgent')) { if (config.get('options.overrideUserAgent')) {
// User agents are from https://developers.whatismybrowser.com/useragents/explore/ // User agents are from https://developers.whatismybrowser.com/useragents/explore/
const originalUserAgent = win.webContents.userAgent; const originalUserAgent = win.webContents.userAgent;
const userAgents = { const userAgents = {
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0', mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0', windows:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0', linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
}; };
const updatedUserAgent const updatedUserAgent = is.macOS()
= is.macOS() ? userAgents.mac ? userAgents.mac
: (is.windows() ? userAgents.windows : is.windows()
: userAgents.linux); ? userAgents.windows
: userAgents.linux;
win.webContents.userAgent = updatedUserAgent; win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent; app.userAgentFallback = updatedUserAgent;
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
// This will only happen if login failed, and "retry" was pressed // This will only happen if login failed, and "retry" was pressed
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) { if (
win.webContents.getURL().startsWith('https://accounts.google.com') &&
details.url.startsWith('https://accounts.google.com')
) {
details.requestHeaders['User-Agent'] = originalUserAgent; details.requestHeaders['User-Agent'] = originalUserAgent;
} }
@ -373,33 +495,41 @@ app.once('browser-window-created', (event, win) => {
setupSongInfo(win); setupSongInfo(win);
setupAppControls(); setupAppControls();
win.webContents.on('did-fail-load', ( win.webContents.on(
_event, 'did-fail-load',
errorCode, (
errorDescription, _event,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
) => {
const log = JSON.stringify({
error: 'did-fail-load',
errorCode, errorCode,
errorDescription, errorDescription,
validatedURL, validatedURL,
isMainFrame, isMainFrame,
frameProcessId, frameProcessId,
frameRoutingId, frameRoutingId,
}, null, '\t'); ) => {
if (is.dev()) { const log = JSON.stringify(
console.log(log); {
} error: 'did-fail-load',
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
},
null,
'\t',
);
if (is.dev()) {
console.log(log);
}
if (errorCode !== -3) { // -3 is a false positive if (errorCode !== -3) {
win.webContents.send('log', log); // -3 is a false positive
win.webContents.loadFile(path.join(__dirname, 'error.html')); win.webContents.send('log', log);
} win.webContents.loadFile(path.join(__dirname, 'error.html'));
}); }
},
);
win.webContents.on('will-prevent-unload', (event) => { win.webContents.on('will-prevent-unload', (event) => {
event.preventDefault(); event.preventDefault();
@ -425,12 +555,30 @@ app.on('activate', async () => {
} }
}); });
app.on('ready', async () => { const getDefaultLocale = (locale: string) =>
Object.keys(languageResources).includes(locale) ? locale : null;
app.whenReady().then(async () => {
if (!config.get('options.language')) {
const locale = getDefaultLocale(app.getLocale());
if (locale) {
config.set('options.language', locale);
}
}
await loadI18n().then(async () => {
await setLanguage(config.get('options.language') ?? 'en');
console.log(LoggerPrefix, t('main.console.i18n.loaded'));
});
if (config.get('options.autoResetAppCache')) { if (config.get('options.autoResetAppCache')) {
// Clear cache after 20s // Clear cache after 20s
const clearCacheTimeout = setTimeout(() => { const clearCacheTimeout = setTimeout(() => {
if (is.dev()) { if (is.dev()) {
console.log('Clearing app cache.'); console.log(
LoggerPrefix,
t('main.console.when-ready.clearing-cache-after-20s'),
);
} }
session.defaultSession.clearCache(); session.defaultSession.clearCache();
@ -445,17 +593,29 @@ app.on('ready', async () => {
const appLocation = process.execPath; const appLocation = process.execPath;
const appData = app.getPath('appData'); const appData = app.getPath('appData');
// Check shortcut validity if not in dev mode / running portable app // Check shortcut validity if not in dev mode / running portable app
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) { if (
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk'); !is.dev() &&
try { // Check if shortcut is registered and valid !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))
) {
const shortcutPath = path.join(
appData,
'Microsoft',
'Windows',
'Start Menu',
'Programs',
'YouTube Music.lnk',
);
try {
// Check if shortcut is registered and valid
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
if ( if (
shortcutDetails.target !== appLocation shortcutDetails.target !== appLocation ||
|| shortcutDetails.appUserModelId !== appID shortcutDetails.appUserModelId !== appID
) { ) {
throw 'needUpdate'; throw 'needUpdate';
} }
} catch (error) { // If not valid -> Register shortcut } catch (error) {
// If not valid -> Register shortcut
shell.writeShortcutLink( shell.writeShortcutLink(
shortcutPath, shortcutPath,
error === 'needUpdate' ? 'update' : 'create', error === 'needUpdate' ? 'update' : 'create',
@ -471,8 +631,8 @@ app.on('ready', async () => {
} }
mainWindow = await createMainWindow(); mainWindow = await createMainWindow();
setApplicationMenu(mainWindow); await setApplicationMenu(mainWindow);
refreshMenu(mainWindow); await refreshMenu(mainWindow);
setUpTray(app, mainWindow); setUpTray(app, mainWindow);
setupProtocolHandler(mainWindow); setupProtocolHandler(mainWindow);
@ -484,7 +644,10 @@ app.on('ready', async () => {
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined; const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
const command = protocolArgv.slice(uri.length, lastIndex); const command = protocolArgv.slice(uri.length, lastIndex);
if (is.dev()) { if (is.dev()) {
console.debug(`Received command over protocol: "${command}"`); console.debug(
LoggerPrefix,
t('main.console.second-instance.receive-command', { command }),
);
} }
handleProtocol(command); handleProtocol(command);
@ -517,16 +680,28 @@ app.on('ready', async () => {
clearTimeout(updateTimeout); clearTimeout(updateTimeout);
}, 2000); }, 2000);
autoUpdater.on('update-available', () => { autoUpdater.on('update-available', () => {
const downloadLink const downloadLink =
= 'https://github.com/th-ch/youtube-music/releases/latest'; 'https://github.com/th-ch/youtube-music/releases/latest';
const dialogOptions: Electron.MessageBoxOptions = { const dialogOptions: Electron.MessageBoxOptions = {
type: 'info', type: 'info',
buttons: ['OK', 'Download', 'Disable updates'], buttons: [
title: 'Application Update', t('main.dialog.update-available.buttons.download'),
message: 'A new version is available', t('main.dialog.update-available.buttons.ok'),
detail: `A new version is available and can be downloaded at ${downloadLink}`, t('main.dialog.update-available.buttons.disable'),
],
title: t('main.dialog.update-available.title'),
message: t('main.dialog.update-available.message'),
detail: t('main.dialog.update-available.detail', { downloadLink }),
}; };
dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
let dialogPromise: Promise<Electron.MessageBoxReturnValue>;
if (mainWindow) {
dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions);
} else {
dialogPromise = dialog.showMessageBox(dialogOptions);
}
dialogPromise.then((dialogOutput) => {
switch (dialogOutput.response) { switch (dialogOutput.response) {
// Download // Download
case 1: { case 1: {
@ -550,8 +725,9 @@ app.on('ready', async () => {
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) { if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(mainWindow, { dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled', type: 'info',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", title: t('main.dialog.hide-menu-enabled.title'),
message: t('main.dialog.hide-menu-enabled.message'),
}); });
config.set('options.hideMenuWarned', true); config.set('options.hideMenuWarned', true);
} }
@ -577,31 +753,45 @@ app.on('ready', async () => {
} }
}); });
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) { function showUnresponsiveDialog(
win: BrowserWindow,
details: Electron.RenderProcessGoneDetails,
) {
if (details) { if (details) {
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t')); console.error(
LoggerPrefix,
t('main.console.unresponsive.details', {
error: JSON.stringify(details, null, '\t'),
}),
);
} }
dialog.showMessageBox(win, { dialog
type: 'error', .showMessageBox(win, {
title: 'Window Unresponsive', type: 'error',
message: 'The Application is Unresponsive', title: t('main.dialog.unresponsive.title'),
detail: 'We are sorry for the inconvenience! please choose what to do:', message: t('main.dialog.unresponsive.message'),
buttons: ['Wait', 'Relaunch', 'Quit'], detail: t('main.dialog.unresponsive.detail'),
cancelId: 0, buttons: [
}).then((result) => { t('main.dialog.unresponsive.buttons.wait'),
switch (result.response) { t('main.dialog.unresponsive.buttons.relaunch'),
case 1: { t('main.dialog.unresponsive.buttons.quit'),
restart(); ],
break; cancelId: 0,
} })
.then((result) => {
switch (result.response) {
case 1: {
restart();
break;
}
case 2: { case 2: {
app.quit(); app.quit();
break; break;
}
} }
} });
});
} }
function removeContentSecurityPolicy( function removeContentSecurityPolicy(
@ -624,18 +814,21 @@ function removeContentSecurityPolicy(
}); });
// When multiple listeners are defined, apply them all // When multiple listeners are defined, apply them all
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => { betterSession.webRequest.setResolver(
return listeners.reduce( 'onHeadersReceived',
async (accumulator, listener) => { async (listeners) => {
const acc = await accumulator; return listeners.reduce(
if (acc.cancel) { async (accumulator, listener) => {
return acc; const acc = await accumulator;
} if (acc.cancel) {
return acc;
}
const result = await listener.apply(); const result = await listener.apply();
return { ...accumulator, ...result }; return { ...accumulator, ...result };
}, },
Promise.resolve({ cancel: false }), Promise.resolve({ cancel: false }),
); );
}); },
);
} }

161
src/loader/main.ts Normal file
View File

@ -0,0 +1,161 @@
import { BrowserWindow, ipcMain } from 'electron';
import { deepmerge } from 'deepmerge-ts';
import { allPlugins, mainPlugins } from 'virtual:plugins';
import config from '@/config';
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
import { t } from '@/i18n';
import type { PluginConfig, PluginDef } from '@/types/plugins';
import type { BackendContext } from '@/types/contexts';
const loadedPluginMap: Record<
string,
PluginDef<unknown, unknown, unknown>
> = {};
const createContext = (
id: string,
win: BrowserWindow,
): BackendContext<PluginConfig> => ({
getConfig: () =>
deepmerge(
allPlugins[id].config ?? { enabled: false },
config.get(`plugins.${id}`) ?? {},
) as PluginConfig,
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
},
ipc: {
send: (event: string, ...args: unknown[]) => {
win.webContents.send(event, ...args);
},
handle: (event: string, listener: CallableFunction) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
},
on: (event: string, listener: CallableFunction) => {
ipcMain.on(event, (_, ...args: unknown[]) => {
listener(...args);
});
},
removeHandler: (event: string) => {
ipcMain.removeHandler(event);
},
},
window: win,
});
export const forceUnloadMainPlugin = async (
id: string,
win: BrowserWindow,
): Promise<void> => {
const plugin = loadedPluginMap[id];
if (!plugin) return;
try {
const hasStopped = await stopPlugin(id, plugin, {
ctx: 'backend',
context: createContext(id, win),
});
if (
hasStopped ||
(hasStopped === null &&
typeof plugin.backend !== 'function' &&
plugin.backend)
) {
delete loadedPluginMap[id];
console.log(
LoggerPrefix,
t('common.console.plugins.unloaded', { pluginName: id }),
);
return;
} else {
console.log(
LoggerPrefix,
t('common.console.plugins.unload-failed', { pluginName: id }),
);
return Promise.reject();
}
} catch (err) {
console.error(
LoggerPrefix,
t('common.console.plugins.unload-failed', { pluginName: id }),
);
console.trace(err);
return Promise.reject(err);
}
};
export const forceLoadMainPlugin = async (
id: string,
win: BrowserWindow,
): Promise<void> => {
const plugin = mainPlugins[id];
if (!plugin) return;
try {
const hasStarted = await startPlugin(id, plugin, {
ctx: 'backend',
context: createContext(id, win),
});
if (
hasStarted ||
(hasStarted === null &&
typeof plugin.backend !== 'function' &&
plugin.backend)
) {
loadedPluginMap[id] = plugin;
} else {
console.log(
LoggerPrefix,
t('common.console.plugins.load-failed', { pluginName: id }),
);
return Promise.reject();
}
} catch (err) {
console.error(
LoggerPrefix,
t('common.console.plugins.initialize-failed', { pluginName: id }),
);
console.trace(err);
return Promise.reject(err);
}
};
export const loadAllMainPlugins = async (win: BrowserWindow) => {
console.log(LoggerPrefix, t('common.console.plugins.load-all'));
const pluginConfigs = config.plugins.getPlugins();
const queue: Promise<void>[] = [];
for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
if (config.enabled) {
queue.push(forceLoadMainPlugin(plugin, win));
} else if (loadedPluginMap[plugin]) {
queue.push(forceUnloadMainPlugin(plugin, win));
}
}
await Promise.allSettled(queue);
};
export const unloadAllMainPlugins = async (win: BrowserWindow) => {
for (const id of Object.keys(loadedPluginMap)) {
await forceUnloadMainPlugin(id, win);
}
};
export const getLoadedMainPlugin = (
id: string,
): PluginDef<unknown, unknown, unknown> | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedMainPlugins = () => {
return loadedPluginMap;
};

91
src/loader/menu.ts Normal file
View File

@ -0,0 +1,91 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import config from '@/config';
import { setApplicationMenu } from '@/menu';
import { LoggerPrefix } from '@/utils';
import { t } from '@/i18n';
import type { MenuContext } from '@/types/contexts';
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import type { PluginConfig } from '@/types/plugins';
const menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
const createContext = (
id: string,
win: BrowserWindow,
): MenuContext<PluginConfig> => ({
getConfig: () =>
deepmerge(
allPlugins[id].config ?? { enabled: false },
config.get(`plugins.${id}`) ?? {},
) as PluginConfig,
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
},
window: win,
refresh: async () => {
await setApplicationMenu(win);
if (config.plugins.isEnabled('in-app-menu')) {
win.webContents.send('refresh-in-app-menu');
}
},
});
export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
try {
const plugin = allPlugins[id];
if (!plugin) return;
const menu = plugin.menu?.(createContext(id, win));
if (menu) {
const result = await menu;
if (result.length > 0) {
menuTemplateMap[id] = result;
} else {
return;
}
} else return;
console.log(
LoggerPrefix,
t('common.console.plugins.loaded', { pluginName: `${id}::menu` }),
);
} catch (err) {
console.error(
LoggerPrefix,
t('common.console.plugins.initialize-failed', {
pluginName: `${id}::menu`,
}),
);
console.trace(err);
}
};
export const loadAllMenuPlugins = async (win: BrowserWindow) => {
const pluginConfigs = config.plugins.getPlugins();
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
const config = deepmerge(
pluginDef.config ?? { enabled: false },
pluginConfigs[pluginId] ?? {},
);
if (config.enabled) {
await forceLoadMenuPlugin(pluginId, win);
}
}
};
export const getMenuTemplate = (
id: string,
): MenuItemConstructorOptions[] | undefined => {
return menuTemplateMap[id];
};
export const getAllMenuTemplate = () => {
return menuTemplateMap;
};

114
src/loader/preload.ts Normal file
View File

@ -0,0 +1,114 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins, preloadPlugins } from 'virtual:plugins';
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
import config from '@/config';
import { t } from '@/i18n';
import type { PreloadContext } from '@/types/contexts';
import type { PluginConfig, PluginDef } from '@/types/plugins';
const loadedPluginMap: Record<
string,
PluginDef<unknown, unknown, unknown>
> = {};
const createContext = (id: string): PreloadContext<PluginConfig> => ({
getConfig: () =>
deepmerge(
allPlugins[id].config ?? { enabled: false },
config.get(`plugins.${id}`) ?? {},
) as PluginConfig,
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
},
});
export const forceUnloadPreloadPlugin = async (id: string) => {
if (!loadedPluginMap[id]) return;
const hasStopped = await stopPlugin(id, loadedPluginMap[id], {
ctx: 'preload',
context: createContext(id),
});
if (hasStopped || (hasStopped === null && loadedPluginMap[id].preload)) {
console.log(
LoggerPrefix,
t('common.console.plugins.unloaded', { pluginName: id }),
);
delete loadedPluginMap[id];
} else {
console.error(
LoggerPrefix,
t('common.console.plugins.unload-failed', { pluginName: id }),
);
}
};
export const forceLoadPreloadPlugin = async (id: string) => {
try {
const plugin = preloadPlugins[id];
if (!plugin) return;
const hasStarted = await startPlugin(id, plugin, {
ctx: 'preload',
context: createContext(id),
});
if (
hasStarted ||
(hasStarted === null &&
typeof plugin.preload !== 'function' &&
plugin.preload)
) {
loadedPluginMap[id] = plugin;
}
console.log(
LoggerPrefix,
t('common.console.plugins.loaded', { pluginName: id }),
);
} catch (err) {
console.error(
LoggerPrefix,
t('common.console.plugins.initialize-failed', { pluginName: id }),
);
console.trace(err);
}
};
export const loadAllPreloadPlugins = () => {
const pluginConfigs = config.plugins.getPlugins();
for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
const config = deepmerge(
pluginDef.config ?? { enable: false },
pluginConfigs[pluginId] ?? {},
);
if (config.enabled) {
forceLoadPreloadPlugin(pluginId);
} else {
if (loadedPluginMap[pluginId]) {
forceUnloadPreloadPlugin(pluginId);
}
}
}
};
export const unloadAllPreloadPlugins = async () => {
for (const id of Object.keys(loadedPluginMap)) {
await forceUnloadPreloadPlugin(id);
}
};
export const getLoadedPreloadPlugin = (
id: string,
): PluginDef<unknown, unknown, unknown> | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedPreloadPlugins = () => {
return loadedPluginMap;
};

145
src/loader/renderer.ts Normal file
View File

@ -0,0 +1,145 @@
import { deepmerge } from 'deepmerge-ts';
import { rendererPlugins } from 'virtual:plugins';
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
import { t } from '@/i18n';
import type { RendererContext } from '@/types/contexts';
import type { PluginConfig, PluginDef } from '@/types/plugins';
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<
string,
PluginDef<unknown, unknown, unknown>
> = {};
export const createContext = <Config extends PluginConfig>(
id: string,
): RendererContext<Config> => ({
getConfig: async () => window.ipcRenderer.invoke('get-config', id),
setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('set-config', id, newConfig);
},
ipc: {
send: (event: string, ...args: unknown[]) => {
window.ipcRenderer.send(event, ...args);
},
invoke: (event: string, ...args: unknown[]) =>
window.ipcRenderer.invoke(event, ...args),
on: (event: string, listener: CallableFunction) => {
window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
listener(...args);
});
},
removeAllListeners: (event: string) => {
window.ipcRenderer.removeAllListeners(event);
},
},
});
export const forceUnloadRendererPlugin = async (id: string) => {
unregisterStyleMap[id]?.forEach((unregister) => unregister());
delete unregisterStyleMap[id];
delete loadedPluginMap[id];
const plugin = rendererPlugins[id];
if (!plugin) return;
const hasStopped = await stopPlugin(id, plugin, {
ctx: 'renderer',
context: createContext(id),
});
if (plugin?.stylesheets) {
document.querySelector(`style#plugin-${id}`)?.remove();
}
if (hasStopped || (hasStopped === null && plugin?.renderer)) {
console.log(
LoggerPrefix,
t('common.console.plugins.unloaded', { pluginName: id }),
);
} else {
console.error(
LoggerPrefix,
t('common.console.plugins.unload-failed', { pluginName: id }),
);
}
};
export const forceLoadRendererPlugin = async (id: string) => {
const plugin = rendererPlugins[id];
if (!plugin) return;
const hasEvaled = await startPlugin(id, plugin, {
ctx: 'renderer',
context: createContext(id),
});
if (
hasEvaled ||
plugin?.stylesheets ||
(hasEvaled === null &&
typeof plugin?.renderer !== 'function' &&
plugin?.renderer)
) {
loadedPluginMap[id] = plugin;
if (plugin?.stylesheets) {
const styleSheetList = plugin.stylesheets.map((style) => {
const styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(style);
return styleSheet;
});
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
...styleSheetList,
];
}
console.log(
LoggerPrefix,
t('common.console.plugins.loaded', { pluginName: id }),
);
} else {
console.log(
LoggerPrefix,
t('common.console.plugins.initialize-failed', { pluginName: id }),
);
}
};
export const loadAllRendererPlugins = async () => {
const pluginConfigs = window.mainConfig.plugins.getPlugins();
for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
if (config.enabled) {
await forceLoadRendererPlugin(pluginId);
} else {
if (loadedPluginMap[pluginId]) {
await forceUnloadRendererPlugin(pluginId);
}
}
}
};
export const unloadAllRendererPlugins = async () => {
for (const id of Object.keys(loadedPluginMap)) {
await forceUnloadRendererPlugin(id);
}
};
export const getLoadedRendererPlugin = (
id: string,
): PluginDef<unknown, unknown, unknown> | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedRendererPlugins = () => {
return loadedPluginMap;
};

View File

@ -1,55 +1,41 @@
import process from 'node:process';
import is from 'electron-is'; import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import {
app,
BrowserWindow,
clipboard,
dialog,
Menu,
MenuItem,
shell,
} from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls'; import { allPlugins } from 'virtual:plugins';
import { languageResources } from 'virtual:i18n';
import config from './config'; import config from './config';
import { restart } from './providers/app-controls';
import { startingPages } from './providers/extracted-data'; import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options'; import promptOptions from './providers/prompt-options';
import adblockerMenu from './plugins/adblocker/menu'; import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
import ambientModeMenu from './plugins/ambient-mode/menu'; import { setLanguage, t } from '@/i18n';
import captionsSelectorMenu from './plugins/captions-selector/menu';
import crossfadeMenu from './plugins/crossfade/menu';
import disableAutoplayMenu from './plugins/disable-autoplay/menu';
import discordMenu from './plugins/discord/menu';
import downloaderMenu from './plugins/downloader/menu';
import inAppMenuTitlebarMenu from './plugins/in-app-menu/menu';
import lyricsGeniusMenu from './plugins/lyrics-genius/menu';
import notificationsMenu from './plugins/notifications/menu';
import pictureInPictureMenu from './plugins/picture-in-picture/menu';
import preciseVolumeMenu from './plugins/precise-volume/menu';
import shortcutsMenu from './plugins/shortcuts/menu';
import videoToggleMenu from './plugins/video-toggle/menu';
import visualizerMenu from './plugins/visualizer/menu';
import { getAvailablePluginNames } from './plugins/utils';
export type MenuTemplate = Electron.MenuItemConstructorOptions[]; export type MenuTemplate = Electron.MenuItemConstructorOptions[];
// True only if in-app-menu was loaded on launch // True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const betaPlugins = ['crossfade', 'lumiastream']; const pluginEnabledMenu = (
plugin: string,
const pluginMenus = { label = '',
'adblocker': adblockerMenu, hasSubmenu = false,
'ambient-mode': ambientModeMenu, refreshMenu: (() => void) | undefined = undefined,
'disable-autoplay': disableAutoplayMenu, ): Electron.MenuItemConstructorOptions => ({
'captions-selector': captionsSelectorMenu,
'crossfade': crossfadeMenu,
'discord': discordMenu,
'downloader': downloaderMenu,
'in-app-menu': inAppMenuTitlebarMenu,
'lyrics-genius': lyricsGeniusMenu,
'notifications': notificationsMenu,
'picture-in-picture': pictureInPictureMenu,
'precise-volume': preciseVolumeMenu,
'shortcuts': shortcutsMenu,
'video-toggle': videoToggleMenu,
'visualizer': visualizerMenu,
};
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
label: label || plugin, label: label || plugin,
type: 'checkbox', type: 'checkbox',
checked: config.plugins.isEnabled(plugin), checked: config.plugins.isEnabled(plugin),
@ -66,78 +52,107 @@ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refre
}, },
}); });
export const refreshMenu = (win: BrowserWindow) => { export const refreshMenu = async (win: BrowserWindow) => {
setApplicationMenu(win); await setApplicationMenu(win);
if (inAppMenuActive) { if (inAppMenuActive) {
win.webContents.send('refreshMenu'); win.webContents.send('refresh-in-app-menu');
} }
}; };
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { export const mainMenuTemplate = async (
win: BrowserWindow,
): Promise<MenuTemplate> => {
const innerRefreshMenu = () => refreshMenu(win); const innerRefreshMenu = () => refreshMenu(win);
await loadAllMenuPlugins(win);
const menuResult = Object.entries(getAllMenuTemplate()).map(
([id, template]) => {
const pluginLabel = allPlugins[id]?.name?.() ?? id;
if (!config.plugins.isEnabled(id)) {
return [
id,
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
] as const;
}
return [
id,
{
label: pluginLabel,
submenu: [
pluginEnabledMenu(
id,
t('main.menu.plugins.enabled'),
true,
innerRefreshMenu,
),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions,
] as const;
},
);
const availablePlugins = Object.keys(allPlugins);
const pluginMenus = availablePlugins
.sort((a, b) => {
const aPluginLabel = allPlugins[a]?.name?.() ?? a;
const bPluginLabel = allPlugins[b]?.name?.() ?? b;
return aPluginLabel.localeCompare(bPluginLabel);
})
.map((id) => {
const predefinedTemplate = menuResult.find((it) => it[0] === id);
if (predefinedTemplate) return predefinedTemplate[1];
const pluginLabel = allPlugins[id]?.name?.() ?? id;
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
});
const availableLanguages = Object.keys(languageResources);
return [ return [
{ {
label: 'Plugins', label: t('main.menu.plugins.label'),
submenu: submenu: pluginMenus,
getAvailablePluginNames().map((pluginName) => {
let pluginLabel = pluginName;
if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]';
}
if (Object.hasOwn(pluginMenus, pluginName)) {
const getPluginMenu = pluginMenus[pluginName as keyof typeof pluginMenus];
if (!config.plugins.isEnabled(pluginName)) {
return pluginEnabledMenu(pluginName, pluginLabel, true, innerRefreshMenu);
}
return {
label: pluginLabel,
submenu: [
pluginEnabledMenu(pluginName, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...getPluginMenu(win, config.plugins.getOptions(pluginName), innerRefreshMenu),
],
} satisfies Electron.MenuItemConstructorOptions;
}
return pluginEnabledMenu(pluginName, pluginLabel);
}),
}, },
{ {
label: 'Options', label: t('main.menu.options.label'),
submenu: [ submenu: [
{ {
label: 'Auto-update', label: t('main.menu.options.submenu.auto-update'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.autoUpdates'), checked: config.get('options.autoUpdates'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.autoUpdates', item.checked); config.setMenuOption('options.autoUpdates', item.checked);
}, },
}, },
{ {
label: 'Resume last song when app starts', label: t('main.menu.options.submenu.resume-on-start'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.resumeOnStart'), checked: config.get('options.resumeOnStart'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.resumeOnStart', item.checked); config.setMenuOption('options.resumeOnStart', item.checked);
}, },
}, },
{ {
label: 'Starting page', label: t('main.menu.options.submenu.starting-page.label'),
submenu: (() => { submenu: (() => {
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({ const subMenuArray: Electron.MenuItemConstructorOptions[] =
label: name, Object.keys(startingPages).map((name) => ({
type: 'radio', label: name,
checked: config.get('options.startingPage') === name, type: 'radio',
click() { checked: config.get('options.startingPage') === name,
config.set('options.startingPage', name); click() {
}, config.set('options.startingPage', name);
})); },
}));
subMenuArray.unshift({ subMenuArray.unshift({
label: 'Unset', label: t('main.menu.options.submenu.starting-page.unset'),
type: 'radio', type: 'radio',
checked: config.get('options.startingPage') === '', checked: config.get('options.startingPage') === '',
click() { click() {
@ -148,21 +163,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
})(), })(),
}, },
{ {
label: 'Visual Tweaks', label: t('main.menu.options.submenu.visual-tweaks.label'),
submenu: [ submenu: [
{ {
label: 'Remove upgrade button', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.remove-upgrade-button',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.removeUpgradeButton'), checked: config.get('options.removeUpgradeButton'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.removeUpgradeButton', item.checked); config.setMenuOption(
'options.removeUpgradeButton',
item.checked,
);
}, },
}, },
{ {
label: 'Like buttons', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.label',
),
submenu: [ submenu: [
{ {
label: 'Default', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.default',
),
type: 'radio', type: 'radio',
checked: !config.get('options.likeButtons'), checked: !config.get('options.likeButtons'),
click() { click() {
@ -170,7 +194,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Force show', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.force-show',
),
type: 'radio', type: 'radio',
checked: config.get('options.likeButtons') === 'force', checked: config.get('options.likeButtons') === 'force',
click() { click() {
@ -178,7 +204,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Hide', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.hide',
),
type: 'radio', type: 'radio',
checked: config.get('options.likeButtons') === 'hide', checked: config.get('options.likeButtons') === 'hide',
click() { click() {
@ -188,10 +216,14 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
], ],
}, },
{ {
label: 'Theme', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.label',
),
submenu: [ submenu: [
{ {
label: 'No theme', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
),
type: 'radio', type: 'radio',
checked: config.get('options.themes')?.length === 0, // Todo rename "themes" checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
click() { click() {
@ -200,7 +232,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Import custom CSS file', label: t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.import-css-file',
),
type: 'normal', type: 'normal',
async click() { async click() {
const { filePaths } = await dialog.showOpenDialog({ const { filePaths } = await dialog.showOpenDialog({
@ -217,10 +251,10 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
], ],
}, },
{ {
label: 'Single instance lock', label: t('main.menu.options.submenu.single-instance-lock'),
type: 'checkbox', type: 'checkbox',
checked: true, checked: true,
click(item) { click(item: MenuItem) {
if (!item.checked && app.hasSingleInstanceLock()) { if (!item.checked && app.hasSingleInstanceLock()) {
app.releaseSingleInstanceLock(); app.releaseSingleInstanceLock();
} else if (item.checked && !app.hasSingleInstanceLock()) { } else if (item.checked && !app.hasSingleInstanceLock()) {
@ -229,51 +263,56 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Always on top', label: t('main.menu.options.submenu.always-on-top'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.alwaysOnTop'), checked: config.get('options.alwaysOnTop'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.alwaysOnTop', item.checked); config.setMenuOption('options.alwaysOnTop', item.checked);
win.setAlwaysOnTop(item.checked); win.setAlwaysOnTop(item.checked);
}, },
}, },
...(is.windows() || is.linux() ...((is.windows() || is.linux()
? [ ? [
{ {
label: 'Hide menu', label: t('main.menu.options.submenu.hide-menu.label'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.hideMenu'), checked: config.get('options.hideMenu'),
click(item) { click(item) {
config.setMenuOption('options.hideMenu', item.checked); config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) { if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, { dialog.showMessageBox(win, {
type: 'info', title: 'Hide Menu Enabled', type: 'info',
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)', title: t(
}); 'main.menu.options.submenu.hide-menu.dialog.title',
} ),
message: t(
'main.menu.options.submenu.hide-menu.dialog.message',
),
});
}
},
}, },
}, ]
] : []) satisfies Electron.MenuItemConstructorOptions[]),
: []) satisfies Electron.MenuItemConstructorOptions[], ...((is.windows() || is.macOS()
...(is.windows() || is.macOS()
? // Only works on Win/Mac ? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows // https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[ [
{ {
label: 'Start at login', label: t('main.menu.options.submenu.start-at-login'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.startAtLogin'), checked: config.get('options.startAtLogin'),
click(item) { click(item) {
config.setMenuOption('options.startAtLogin', item.checked); config.setMenuOption('options.startAtLogin', item.checked);
},
}, },
}, ]
] : []) satisfies Electron.MenuItemConstructorOptions[]),
: []) satisfies Electron.MenuItemConstructorOptions[],
{ {
label: 'Tray', label: t('main.menu.options.submenu.tray.label'),
submenu: [ submenu: [
{ {
label: 'Disabled', label: t('main.menu.options.submenu.tray.submenu.disabled'),
type: 'radio', type: 'radio',
checked: !config.get('options.tray'), checked: !config.get('options.tray'),
click() { click() {
@ -282,18 +321,24 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Enabled + app visible', label: t(
'main.menu.options.submenu.tray.submenu.enabled-and-show-app',
),
type: 'radio', type: 'radio',
checked: config.get('options.tray') && config.get('options.appVisible'), checked:
config.get('options.tray') && config.get('options.appVisible'),
click() { click() {
config.setMenuOption('options.tray', true); config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', true); config.setMenuOption('options.appVisible', true);
}, },
}, },
{ {
label: 'Enabled + app hidden', label: t(
'main.menu.options.submenu.tray.submenu.enabled-and-hide-app',
),
type: 'radio', type: 'radio',
checked: config.get('options.tray') && !config.get('options.appVisible'), checked:
config.get('options.tray') && !config.get('options.appVisible'),
click() { click() {
config.setMenuOption('options.tray', true); config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', false); config.setMenuOption('options.appVisible', false);
@ -301,75 +346,143 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Play/Pause on click', label: t(
'main.menu.options.submenu.tray.submenu.play-pause-on-click',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.trayClickPlayPause'), checked: config.get('options.trayClickPlayPause'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.trayClickPlayPause', item.checked); config.setMenuOption(
'options.trayClickPlayPause',
item.checked,
);
}, },
}, },
], ],
}, },
{ type: 'separator' },
{ {
label: 'Advanced options', label: t('main.menu.options.submenu.language.label') + ' (Language)',
submenu: [ submenu: [
{ {
label: 'Set Proxy', label: t(
'main.menu.options.submenu.language.submenu.to-help-translate',
),
type: 'normal', type: 'normal',
async click(item) { click() {
const url = 'https://hosted.weblate.org/engage/youtube-music/';
shell.openExternal(url);
},
} as Electron.MenuItemConstructorOptions,
].concat(
availableLanguages
.map(
(lang): Electron.MenuItemConstructorOptions => ({
label: `${languageResources[lang].translation.language?.name ?? 'Unknown'} (${languageResources[lang].translation.language?.['local-name'] ?? 'Unknown'})`,
type: 'checkbox',
checked: (config.get('options.language') ?? 'en') === lang,
click() {
config.setMenuOption('options.language', lang);
refreshMenu(win);
setLanguage(lang);
dialog.showMessageBox(win, {
title: t(
'main.menu.options.submenu.language.dialog.title',
),
message: t(
'main.menu.options.submenu.language.dialog.message',
),
});
},
}),
)
.sort((a, b) => a.label!.localeCompare(b.label!)),
),
},
{ type: 'separator' },
{
label: t('main.menu.options.submenu.advanced-options.label'),
submenu: [
{
label: t(
'main.menu.options.submenu.advanced-options.submenu.set-proxy.label',
),
type: 'normal',
async click(item: MenuItem) {
await setProxy(item, win); await setProxy(item, win);
}, },
}, },
{ {
label: 'Override useragent', label: t(
'main.menu.options.submenu.advanced-options.submenu.override-user-agent',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.overrideUserAgent'), checked: config.get('options.overrideUserAgent'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.overrideUserAgent', item.checked); config.setMenuOption('options.overrideUserAgent', item.checked);
}, },
}, },
{ {
label: 'Disable hardware acceleration', label: t(
'main.menu.options.submenu.advanced-options.submenu.disable-hardware-acceleration',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.disableHardwareAcceleration'), checked: config.get('options.disableHardwareAcceleration'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.disableHardwareAcceleration', item.checked); config.setMenuOption(
'options.disableHardwareAcceleration',
item.checked,
);
}, },
}, },
{ {
label: 'Restart on config changes', label: t(
'main.menu.options.submenu.advanced-options.submenu.restart-on-config-changes',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.restartOnConfigChanges'), checked: config.get('options.restartOnConfigChanges'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.restartOnConfigChanges', item.checked); config.setMenuOption(
'options.restartOnConfigChanges',
item.checked,
);
}, },
}, },
{ {
label: 'Reset App cache when app starts', label: t(
'main.menu.options.submenu.advanced-options.submenu.auto-reset-app-cache',
),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.autoResetAppCache'), checked: config.get('options.autoResetAppCache'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.autoResetAppCache', item.checked); config.setMenuOption('options.autoResetAppCache', item.checked);
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
is.macOS() is.macOS()
? { ? {
label: 'Toggle DevTools', label: t(
// Cannot use "toggleDevTools" role in macOS 'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
click() { ),
const { webContents } = win; // Cannot use "toggleDevTools" role in macOS
if (webContents.isDevToolsOpened()) { click() {
webContents.closeDevTools(); const { webContents } = win;
} else { if (webContents.isDevToolsOpened()) {
webContents.openDevTools(); webContents.closeDevTools();
} } else {
webContents.openDevTools();
}
},
}
: {
label: t(
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
),
role: 'toggleDevTools',
}, },
}
: { role: 'toggleDevTools' },
{ {
label: 'Edit config.json', label: t(
'main.menu.options.submenu.advanced-options.submenu.edit-config-json',
),
click() { click() {
config.edit(); config.edit();
}, },
@ -379,23 +492,55 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
], ],
}, },
{ {
label: 'View', label: t('main.menu.view.label'),
submenu: [ submenu: [
{ role: 'reload' }, {
{ role: 'forceReload' }, label: t('main.menu.view.submenu.reload'),
role: 'reload',
},
{
label: t('main.menu.view.submenu.force-reload'),
role: 'forceReload',
},
{ type: 'separator' }, { type: 'separator' },
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' }, {
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' }, label: t('main.menu.view.submenu.zoom-in'),
{ role: 'resetZoom' }, role: 'zoomIn',
accelerator: 'CmdOrCtrl+=',
visible: false,
},
{
label: t('main.menu.view.submenu.zoom-in'),
role: 'zoomIn',
accelerator: 'CmdOrCtrl+Plus',
},
{
label: t('main.menu.view.submenu.zoom-out'),
role: 'zoomOut',
accelerator: 'CmdOrCtrl+-',
},
{
label: t('main.menu.view.submenu.zoom-out'),
role: 'zoomOut',
accelerator: 'CmdOrCtrl+Shift+-',
visible: false,
},
{
label: t('main.menu.view.submenu.reset-zoom'),
role: 'resetZoom',
},
{ type: 'separator' }, { type: 'separator' },
{ role: 'togglefullscreen' }, {
label: t('main.menu.view.submenu.toggle-fullscreen'),
role: 'togglefullscreen',
},
], ],
}, },
{ {
label: 'Navigation', label: t('main.menu.navigation.label'),
submenu: [ submenu: [
{ {
label: 'Go back', label: t('main.menu.navigation.submenu.go-back'),
click() { click() {
if (win.webContents.canGoBack()) { if (win.webContents.canGoBack()) {
win.webContents.goBack(); win.webContents.goBack();
@ -403,7 +548,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Go forward', label: t('main.menu.navigation.submenu.go-forward'),
click() { click() {
if (win.webContents.canGoForward()) { if (win.webContents.canGoForward()) {
win.webContents.goForward(); win.webContents.goForward();
@ -411,29 +556,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
}, },
{ {
label: 'Copy current URL', label: t('main.menu.navigation.submenu.copy-current-url'),
click() { click() {
const currentURL = win.webContents.getURL(); const currentURL = win.webContents.getURL();
clipboard.writeText(currentURL); clipboard.writeText(currentURL);
}, },
}, },
{ {
label: 'Restart App', label: t('main.menu.navigation.submenu.restart'),
click: restart, click: restart,
}, },
{ role: 'quit' }, {
label: t('main.menu.navigation.submenu.quit'),
role: 'quit',
},
], ],
}, },
{ {
label: 'About', label: t('main.menu.about'),
submenu: [ submenu: [{ role: 'about' }],
{ role: 'about' }, },
],
}
]; ];
}; };
export const setApplicationMenu = (win: Electron.BrowserWindow) => { export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)]; const menuTemplate: MenuTemplate = [...(await mainMenuTemplate(win))];
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const { name } = app; const { name } = app;
menuTemplate.unshift({ menuTemplate.unshift({
@ -462,23 +608,33 @@ export const setApplicationMenu = (win: Electron.BrowserWindow) => {
}; };
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) { async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
const output = await prompt({ const output = await prompt(
title: 'Set Proxy', {
label: 'Enter Proxy Address: (leave empty to disable)', title: t(
value: config.get('options.proxy'), 'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.title',
type: 'input', ),
inputAttrs: { label: t(
type: 'url', 'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.label',
placeholder: "Example: 'socks5://127.0.0.1:9999", ),
value: config.get('options.proxy'),
type: 'input',
inputAttrs: {
type: 'url',
placeholder: t(
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.placeholder',
),
},
width: 450,
...promptOptions(),
}, },
width: 450, win,
...promptOptions(), );
}, win);
if (typeof output === 'string') { if (typeof output === 'string') {
config.setMenuOption('options.proxy', output); config.setMenuOption('options.proxy', output);
item.checked = output !== ''; item.checked = output !== '';
} else { // User pressed cancel } else {
// User pressed cancel
item.checked = !item.checked; // Reset checkbox item.checked = !item.checked; // Reset checkbox
} }
} }

7
src/navigation.d.ts vendored
View File

@ -62,7 +62,10 @@ interface Navigation extends EventTarget {
onnavigateerror: ((this: Navigation, ev: Event) => any) | null; onnavigateerror: ((this: Navigation, ev: Event) => any) | null;
oncurrententrychange: ((this: Navigation, ev: Event) => any) | null; oncurrententrychange: ((this: Navigation, ev: Event) => any) | null;
addEventListener<K extends keyof NavigationEventsMap>(name: K, listener: (event: NavigationEventsMap[K]) => void); addEventListener<K extends keyof NavigationEventsMap>(
name: K,
listener: (event: NavigationEventsMap[K]) => void,
);
} }
declare class NavigateEvent extends Event { declare class NavigateEvent extends Event {
@ -84,5 +87,5 @@ type NavigationHistoryBehavior = 'auto' | 'push' | 'replace';
declare const Navigation: { declare const Navigation: {
prototype: Navigation; prototype: Navigation;
new(): Navigation; new (): Navigation;
}; };

View File

@ -1,19 +0,0 @@
import { BrowserWindow } from 'electron';
import { loadAdBlockerEngine } from './blocker';
import { shouldUseBlocklists } from './config';
import type { ConfigType } from '../../config/dynamic';
type AdBlockOptions = ConfigType<'adblocker'>;
export default async (win: BrowserWindow, options: AdBlockOptions) => {
if (shouldUseBlocklists()) {
await loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

View File

@ -17,10 +17,12 @@ const SOURCES = [
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
]; ];
let blocker: ElectronBlocker | undefined;
export const loadAdBlockerEngine = async ( export const loadAdBlockerEngine = async (
session: Electron.Session | undefined = undefined, session: Electron.Session | undefined = undefined,
cache = true, cache: boolean = true,
additionalBlockLists = [], additionalBlockLists: string[] = [],
disableDefaultLists: boolean | unknown[] = false, disableDefaultLists: boolean | unknown[] = false,
) => { ) => {
// Only use cache if no additional blocklists are passed // Only use cache if no additional blocklists are passed
@ -28,24 +30,24 @@ export const loadAdBlockerEngine = async (
if (!fs.existsSync(cacheDirectory)) { if (!fs.existsSync(cacheDirectory)) {
fs.mkdirSync(cacheDirectory); fs.mkdirSync(cacheDirectory);
} }
const cachingOptions const cachingOptions =
= cache && additionalBlockLists.length === 0 cache && additionalBlockLists.length === 0
? { ? {
path: path.join(cacheDirectory, 'adblocker-engine.bin'), path: path.join(cacheDirectory, 'adblocker-engine.bin'),
read: promises.readFile, read: promises.readFile,
write: promises.writeFile, write: promises.writeFile,
} }
: undefined; : undefined;
const lists = [ const lists = [
...( ...((disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
(disableDefaultLists && !Array.isArray(disableDefaultLists)) || (Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0)
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES ? []
), : SOURCES),
...additionalBlockLists, ...additionalBlockLists,
]; ];
try { try {
const blocker = await ElectronBlocker.fromLists( blocker = await ElectronBlocker.fromLists(
(url: string) => net.fetch(url), (url: string) => net.fetch(url),
lists, lists,
{ {
@ -64,4 +66,11 @@ export const loadAdBlockerEngine = async (
} }
}; };
export default { loadAdBlockerEngine }; export const unloadAdBlockerEngine = (session: Electron.Session) => {
if (blocker) {
blocker.disableBlockingInSession(session);
}
};
export const isBlockerEnabled = (session: Electron.Session) =>
blocker !== undefined && blocker.isBlockingEnabled(session);

View File

@ -1,15 +0,0 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { blockers } from './blocker-types';
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('adblocker', { enableFront: true });
export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer;
export default Object.assign(config, {
shouldUseBlocklists,
blockers,
});

View File

@ -0,0 +1,137 @@
import { blockers } from './types';
import { createPlugin } from '@/utils';
import {
isBlockerEnabled,
loadAdBlockerEngine,
unloadAdBlockerEngine,
} from './blocker';
import injectCliqzPreload from './injectors/inject-cliqz-preload';
import { inject, isInjected } from './injectors/inject';
import { t } from '@/i18n';
import type { BrowserWindow } from 'electron';
interface AdblockerConfig {
/**
* Whether to enable the adblocker.
* @default true
*/
enabled: boolean;
/**
* When enabled, the adblocker will cache the blocklists.
* @default true
*/
cache: boolean;
/**
* Which adblocker to use.
* @default blockers.InPlayer
*/
blocker: (typeof blockers)[keyof typeof blockers];
/**
* Additional list of filters to use.
* @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"]
* @default []
*/
additionalBlockLists: string[];
/**
* Disable the default blocklists.
* @default false
*/
disableDefaultLists: boolean;
}
export default createPlugin({
name: () => t('plugins.adblocker.name'),
description: () => t('plugins.adblocker.description'),
restartNeeded: false,
config: {
enabled: true,
cache: true,
blocker: blockers.InPlayer,
additionalBlockLists: [],
disableDefaultLists: false,
} as AdblockerConfig,
menu: async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: t('plugins.adblocker.menu.blocker'),
submenu: Object.values(blockers).map((blocker) => ({
label: blocker,
type: 'radio',
checked: (config.blocker || blockers.WithBlocklists) === blocker,
click() {
setConfig({ blocker });
},
})),
},
];
},
backend: {
mainWindow: null as BrowserWindow | null,
async start({ getConfig, window }) {
const config = await getConfig();
this.mainWindow = window;
if (config.blocker === blockers.WithBlocklists) {
await loadAdBlockerEngine(
window.webContents.session,
config.cache,
config.additionalBlockLists,
config.disableDefaultLists,
);
}
},
stop({ window }) {
if (isBlockerEnabled(window.webContents.session)) {
unloadAdBlockerEngine(window.webContents.session);
}
},
async onConfigChange(newConfig) {
if (this.mainWindow) {
if (
newConfig.blocker === blockers.WithBlocklists &&
!isBlockerEnabled(this.mainWindow.webContents.session)
) {
await loadAdBlockerEngine(
this.mainWindow.webContents.session,
newConfig.cache,
newConfig.additionalBlockLists,
newConfig.disableDefaultLists,
);
}
}
},
},
preload: {
async start({ getConfig }) {
const config = await getConfig();
if (config.blocker === blockers.WithBlocklists) {
// Preload adblocker to inject scripts/styles
await injectCliqzPreload();
}
},
async onConfigChange(newConfig) {
if (newConfig.blocker === blockers.WithBlocklists) {
await injectCliqzPreload();
}
},
},
renderer: {
async start({ getConfig }) {
const config = await getConfig();
if (config.blocker === blockers.InPlayer && !isInjected()) {
inject();
}
},
onConfigChange(newConfig) {
if (newConfig.blocker === blockers.InPlayer && !isInjected()) {
inject();
}
},
}
});

View File

@ -1,3 +0,0 @@
const inject: () => void;
export default inject;

View File

@ -0,0 +1,3 @@
export const inject: () => void;
export const isInjected: () => boolean;

View File

@ -7,7 +7,13 @@
Parts of this code is derived from set-constant.js: Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/ */
module.exports = () => {
let injected = false;
export const isInjected = () => injected;
export const inject = () => {
injected = true;
{ {
const pruner = function (o) { const pruner = function (o) {
delete o.playerAds; delete o.playerAds;
@ -67,8 +73,7 @@ module.exports = () => {
} }
case 'noopFunc': { case 'noopFunc': {
cValue = function () { cValue = function () {};
};
break; break;
} }
@ -97,7 +102,7 @@ module.exports = () => {
return; return;
} }
if (Math.abs(cValue) > 0x7F_FF) { if (Math.abs(cValue) > 0x7f_ff) {
return; return;
} }
} else { } else {
@ -113,12 +118,12 @@ module.exports = () => {
return true; return true;
} }
aborted aborted =
= v !== undefined v !== undefined &&
&& v !== null v !== null &&
&& cValue !== undefined cValue !== undefined &&
&& cValue !== null cValue !== null &&
&& typeof v !== typeof cValue; typeof v !== typeof cValue;
return aborted; return aborted;
}; };
@ -266,8 +271,7 @@ module.exports = () => {
} }
case 'noopFunc': { case 'noopFunc': {
cValue = function () { cValue = function () {};
};
break; break;
} }
@ -296,7 +300,7 @@ module.exports = () => {
return; return;
} }
if (Math.abs(cValue) > 0x7F_FF) { if (Math.abs(cValue) > 0x7f_ff) {
return; return;
} }
} else { } else {
@ -312,12 +316,12 @@ module.exports = () => {
return true; return true;
} }
aborted aborted =
= v !== undefined v !== undefined &&
&& v !== null v !== null &&
&& cValue !== undefined cValue !== undefined &&
&& cValue !== null cValue !== null &&
&& typeof v !== typeof cValue; typeof v !== typeof cValue;
return aborted; return aborted;
}; };

View File

@ -1,21 +0,0 @@
import config from './config';
import { blockers } from './blocker-types';
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => {
return [
{
label: 'Blocker',
submenu: Object.values(blockers).map((blocker: string) => ({
label: blocker,
type: 'radio',
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
click() {
config.set('blocker', blocker);
},
})),
},
];
};

View File

@ -1,15 +0,0 @@
import config, { shouldUseBlocklists } from './config';
import inject from './inject';
import injectCliqzPreload from './inject-cliqz-preload';
import { blockers } from './blocker-types';
export default async () => {
if (shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
await injectCliqzPreload();
// eslint-disable-next-line @typescript-eslint/await-thenable
} else if ((config.get('blocker')) === blockers.InPlayer) {
inject();
}
};

View File

@ -1,9 +0,0 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -1,127 +0,0 @@
import { FastAverageColor } from 'fast-average-color';
import { ConfigType } from '../../config/dynamic';
function hexToHSL(H: string) {
// Convert hex to RGB first
let r = 0;
let g = 0;
let b = 0;
if (H.length == 4) {
r = Number('0x' + H[1] + H[1]);
g = Number('0x' + H[2] + H[2]);
b = Number('0x' + H[3] + H[3]);
} else if (H.length == 7) {
r = Number('0x' + H[1] + H[2]);
g = Number('0x' + H[3] + H[4]);
b = Number('0x' + H[5] + H[6]);
}
// Then to HSL
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
let h: number;
let s: number;
let l: number;
if (delta == 0) {
h = 0;
} else if (cmax == r) {
h = ((g - b) / delta) % 6;
} else if (cmax == g) {
h = ((b - r) / delta) + 2;
} else {
h = ((r - g) / delta) + 4;
}
h = Math.round(h * 60);
if (h < 0) {
h += 360;
}
l = (cmax + cmin) / 2;
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
//return "hsl(" + h + "," + s + "%," + l + "%)";
return [h,s,l];
}
let hue = 0;
let saturation = 0;
let lightness = 0;
function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){
if (element) {
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
}
export default (_: ConfigType<'album-color-theme'>) => {
// updated elements
const playerPage = document.querySelector<HTMLElement>('#player-page');
const navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
const ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
const playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
const sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
const sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
} else {
if (sidebarSmall) {
sidebarSmall.style.backgroundColor = 'black';
}
}
}
}
});
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
document.addEventListener('apiLoaded', (apiEvent) => {
const fastAverageColor = new FastAverageColor();
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
if (name === 'dataloaded') {
const playerResponse = apiEvent.detail.getPlayerResponse();
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
if (thumbnail) {
fastAverageColor.getColorAsync(thumbnail.url)
.then((albumColor) => {
if (albumColor) {
[hue, saturation, lightness] = hexToHSL(albumColor.hex);
changeElementColor(playerPage, hue, saturation, lightness - 30);
changeElementColor(navBarBackground, hue, saturation, lightness - 15);
changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15);
changeElementColor(playerBarBackground, hue, saturation, lightness - 15);
changeElementColor(sidebarBig, hue, saturation, lightness - 15);
if (ytmusicAppLayout?.hasAttribute('player-page-open')) {
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
}
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
} else {
if (playerPage) {
playerPage.style.backgroundColor = '#000000';
}
}
})
.catch((e) => console.error(e));
}
}
});
});
};

View File

@ -0,0 +1,215 @@
import { FastAverageColor } from 'fast-average-color';
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import type { VideoDataChanged } from '@/types/video-data-changed';
export default createPlugin({
name: () => t('plugins.album-color-theme.name'),
description: () => t('plugins.album-color-theme.description'),
restartNeeded: true,
config: {
enabled: false,
},
stylesheets: [style],
renderer: {
hexToHSL: (H: string) => {
// Convert hex to RGB first
let r = 0;
let g = 0;
let b = 0;
if (H.length == 4) {
r = Number('0x' + H[1] + H[1]);
g = Number('0x' + H[2] + H[2]);
b = Number('0x' + H[3] + H[3]);
} else if (H.length == 7) {
r = Number('0x' + H[1] + H[2]);
g = Number('0x' + H[3] + H[4]);
b = Number('0x' + H[5] + H[6]);
}
// Then to HSL
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
let h: number;
let s: number;
let l: number;
if (delta == 0) {
h = 0;
} else if (cmax == r) {
h = ((g - b) / delta) % 6;
} else if (cmax == g) {
h = ((b - r) / delta) + 2;
} else {
h = ((r - g) / delta) + 4;
}
h = Math.round(h * 60);
if (h < 0) {
h += 360;
}
l = (cmax + cmin) / 2;
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
//return "hsl(" + h + "," + s + "%," + l + "%)";
return [h, s, l];
},
hue: 0,
saturation: 0,
lightness: 0,
changeElementColor: (
element: HTMLElement | null,
hue: number,
saturation: number,
lightness: number,
) => {
if (element) {
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
},
playerPage: null as HTMLElement | null,
navBarBackground: null as HTMLElement | null,
ytmusicPlayerBar: null as HTMLElement | null,
playerBarBackground: null as HTMLElement | null,
sidebarBig: null as HTMLElement | null,
sidebarSmall: null as HTMLElement | null,
ytmusicAppLayout: null as HTMLElement | null,
start() {
this.playerPage = document.querySelector<HTMLElement>('#player-page');
this.navBarBackground = document.querySelector<HTMLElement>(
'#nav-bar-background',
);
this.ytmusicPlayerBar =
document.querySelector<HTMLElement>('ytmusic-player-bar');
this.playerBarBackground = document.querySelector<HTMLElement>(
'#player-bar-background',
);
this.sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
this.sidebarSmall = document.querySelector<HTMLElement>(
'#mini-guide-background',
);
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen =
this.ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
this.changeElementColor(
this.sidebarSmall,
this.hue,
this.saturation,
this.lightness - 30,
);
} else {
if (this.sidebarSmall) {
this.sidebarSmall.style.backgroundColor = 'black';
}
}
}
}
});
if (this.playerPage) {
observer.observe(this.playerPage, { attributes: true });
}
},
onPlayerApiReady(playerApi) {
const fastAverageColor = new FastAverageColor();
document.addEventListener(
'videodatachange',
(event: CustomEvent<VideoDataChanged>) => {
if (event.detail.name === 'dataloaded') {
const playerResponse = playerApi.getPlayerResponse();
const thumbnail =
playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
if (thumbnail) {
fastAverageColor
.getColorAsync(thumbnail.url)
.then((albumColor) => {
if (albumColor) {
const [hue, saturation, lightness] = ([
this.hue,
this.saturation,
this.lightness,
] = this.hexToHSL(albumColor.hex));
this.changeElementColor(
this.playerPage,
hue,
saturation,
lightness - 30,
);
this.changeElementColor(
this.navBarBackground,
hue,
saturation,
lightness - 15,
);
this.changeElementColor(
this.ytmusicPlayerBar,
hue,
saturation,
lightness - 15,
);
this.changeElementColor(
this.playerBarBackground,
hue,
saturation,
lightness - 15,
);
this.changeElementColor(
this.sidebarBig,
hue,
saturation,
lightness - 15,
);
if (
this.ytmusicAppLayout?.hasAttribute('player-page-open')
) {
this.changeElementColor(
this.sidebarSmall,
hue,
saturation,
lightness - 30,
);
}
const ytRightClickList =
document.querySelector<HTMLElement>(
'tp-yt-paper-listbox',
);
this.changeElementColor(
ytRightClickList,
hue,
saturation,
lightness - 15,
);
} else {
if (this.playerPage) {
this.playerPage.style.backgroundColor = '#000000';
}
}
})
.catch((e) => console.error(e));
}
}
},
);
},
},
});

View File

@ -4,23 +4,33 @@ yt-page-navigation-progress {
} }
#player-page { #player-page {
transition: transform 300ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important; transition:
transform 300ms,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
} }
#nav-bar-background { #nav-bar-background {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important; transition:
opacity 200ms,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
} }
#mini-guide-background { #mini-guide-background {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important; transition:
opacity 200ms,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
border-right: 0px !important; border-right: 0px !important;
} }
#guide-wrapper { #guide-wrapper {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important; transition:
opacity 200ms,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
} }
#img, #player, .song-media-controls.style-scope.ytmusic-player { #img,
#player,
.song-media-controls.style-scope.ytmusic-player {
border-radius: 2% !important; border-radius: 2% !important;
} }

View File

@ -1,14 +0,0 @@
import { BrowserWindow } from 'electron';
import config from './config';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
config.subscribeAll((newConfig) => {
win.webContents.send('ambient-mode:config-change', newConfig);
});
injectCSS(win.webContents, style);
};

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('ambient-mode');
export default config;

View File

@ -1,167 +0,0 @@
import { ipcRenderer } from 'electron';
import { ConfigType } from '../../config/dynamic';
export default (config: ConfigType<'ambient-mode'>) => {
let interpolationTime = config.interpolationTime; // interpolation time (ms)
let buffer = config.buffer; // frame
let qualityRatio = config.quality; // width size (pixel)
let sizeRatio = config.size / 100; // size ratio (percent)
let blur = config.blur; // blur (pixel)
let opacity = config.opacity; // opacity (percent)
let isFullscreen = config.fullscreen; // fullscreen (boolean)
let unregister: (() => void) | null = null;
const injectBlurVideo = (): (() => void) | null => {
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
const wrapper = document.querySelector('#song-video > .player-wrapper');
if (!songVideo) return null;
if (!video) return null;
if (!wrapper) return null;
const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas');
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
/* effect */
let lastEffectWorkId: number | null = null;
let lastImageData: ImageData | null = null;
const onSync = () => {
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
lastEffectWorkId = requestAnimationFrame(() => {
if (!context) return;
const width = qualityRatio;
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
if (!Number.isFinite(height)) height = width;
context.globalAlpha = 1;
if (lastImageData) {
const frameOffset = (1 / buffer) * (1000 / interpolationTime);
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
context.putImageData(lastImageData, 0, 0);
context.globalAlpha = frameOffset;
}
context.drawImage(video, 0, 0, width, height);
const nowImageData = context.getImageData(0, 0, width, height);
lastImageData = nowImageData;
lastEffectWorkId = null;
});
};
const applyVideoAttributes = () => {
const rect = video.getBoundingClientRect();
const newWidth = Math.floor(video.width || rect.width);
const newHeight = Math.floor(video.height || rect.height);
if (newWidth === 0 || newHeight === 0) return;
blurCanvas.width = qualityRatio;
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
blurCanvas.style.width = `${newWidth * sizeRatio}px`;
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
if (isFullscreen) blurCanvas.classList.add('fullscreen');
else blurCanvas.classList.remove('fullscreen');
const leftOffset = newWidth * (sizeRatio - 1) / 2;
const topOffset = newHeight * (sizeRatio - 1) / 2;
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
blurCanvas.style.setProperty('--blur', `${blur}px`);
blurCanvas.style.setProperty('--opacity', `${opacity}`);
};
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes') {
applyVideoAttributes();
}
});
});
const resizeObserver = new ResizeObserver(() => {
applyVideoAttributes();
});
const onConfigSync = (_: Electron.IpcRendererEvent, newConfig: ConfigType<'ambient-mode'>) => {
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime;
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer;
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality;
if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100;
if (typeof newConfig.blur === 'number') blur = newConfig.blur;
if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity;
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen;
applyVideoAttributes();
};
ipcRenderer.on('ambient-mode:config-change', onConfigSync);
/* hooking */
let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
applyVideoAttributes();
observer.observe(songVideo, { attributes: true });
resizeObserver.observe(songVideo);
window.addEventListener('resize', applyVideoAttributes);
const onPause = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = null;
};
const onPlay = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
};
songVideo.addEventListener('pause', onPause);
songVideo.addEventListener('play', onPlay);
/* injecting */
wrapper.prepend(blurCanvas);
/* cleanup */
return () => {
if (canvasInterval) clearInterval(canvasInterval);
songVideo.removeEventListener('pause', onPause);
songVideo.removeEventListener('play', onPlay);
observer.disconnect();
resizeObserver.disconnect();
ipcRenderer.off('ambient-mode:config-change', onConfigSync);
window.removeEventListener('resize', applyVideoAttributes);
wrapper.removeChild(blurCanvas);
};
};
const playerPage = document.querySelector<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
unregister?.();
unregister = injectBlurVideo() ?? null;
} else {
unregister?.();
unregister = null;
}
}
}
});
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
};

View File

@ -0,0 +1,329 @@
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
export type AmbientModePluginConfig = {
enabled: boolean;
quality: number;
buffer: number;
interpolationTime: number;
blur: number;
size: number;
opacity: number;
fullscreen: boolean;
};
const defaultConfig: AmbientModePluginConfig = {
enabled: false,
quality: 50,
buffer: 30,
interpolationTime: 1500,
blur: 100,
size: 100,
opacity: 1,
fullscreen: false,
};
export default createPlugin({
name: () => t('plugins.ambient-mode.name'),
description: () => t('plugins.ambient-mode.description'),
restartNeeded: false,
config: defaultConfig,
stylesheets: [style],
menu: async ({ getConfig, setConfig }) => {
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
const sizeList = [100, 110, 125, 150, 175, 200, 300];
const bufferList = [1, 5, 10, 20, 30];
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
const config = await getConfig();
return [
{
label: t('plugins.ambient-mode.menu.smoothness-transition.label'),
submenu: interpolationTimeList.map((interpolationTime) => ({
label: t(
'plugins.ambient-mode.menu.smoothness-transition.submenu.during',
{
interpolationTime: interpolationTime / 1000,
},
),
type: 'radio',
checked: config.interpolationTime === interpolationTime,
click() {
setConfig({ interpolationTime });
},
})),
},
{
label: t('plugins.ambient-mode.menu.quality.label'),
submenu: qualityList.map((quality) => ({
label: t('plugins.ambient-mode.menu.quality.submenu.pixels', {
quality,
}),
type: 'radio',
checked: config.quality === quality,
click() {
setConfig({ quality });
},
})),
},
{
label: t('plugins.ambient-mode.menu.size.label'),
submenu: sizeList.map((size) => ({
label: t('plugins.ambient-mode.menu.size.submenu.percent', { size }),
type: 'radio',
checked: config.size === size,
click() {
setConfig({ size });
},
})),
},
{
label: t('plugins.ambient-mode.menu.buffer.label'),
submenu: bufferList.map((buffer) => ({
label: t('plugins.ambient-mode.menu.buffer.submenu.buffer', {
buffer,
}),
type: 'radio',
checked: config.buffer === buffer,
click() {
setConfig({ buffer });
},
})),
},
{
label: t('plugins.ambient-mode.menu.opacity.label'),
submenu: opacityList.map((opacity) => ({
label: t('plugins.ambient-mode.menu.opacity.submenu.percent', {
opacity: opacity * 100,
}),
type: 'radio',
checked: config.opacity === opacity,
click() {
setConfig({ opacity });
},
})),
},
{
label: t('plugins.ambient-mode.menu.blur-amount.label'),
submenu: blurAmountList.map((blur) => ({
label: t('plugins.ambient-mode.menu.blur-amount.submenu.pixels', {
blurAmount: blur,
}),
type: 'radio',
checked: config.blur === blur,
click() {
setConfig({ blur });
},
})),
},
{
label: t('plugins.ambient-mode.menu.use-fullscreen.label'),
type: 'checkbox',
checked: config.fullscreen,
click(item) {
setConfig({ fullscreen: item.checked });
},
},
];
},
renderer: {
interpolationTime: defaultConfig.interpolationTime,
buffer: defaultConfig.buffer,
qualityRatio: defaultConfig.quality,
sizeRatio: defaultConfig.size / 100,
blur: defaultConfig.blur,
opacity: defaultConfig.opacity,
isFullscreen: defaultConfig.fullscreen,
unregister: null as (() => void) | null,
update: null as (() => void) | null,
observer: null as MutationObserver | null,
start() {
const injectBlurVideo = (): (() => void) | null => {
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const video = document.querySelector<HTMLVideoElement>(
'#song-video .html5-video-container > video',
);
const wrapper = document.querySelector('#song-video > .player-wrapper');
if (!songVideo) return null;
if (!video) return null;
if (!wrapper) return null;
const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas');
const context = blurCanvas.getContext('2d', {
willReadFrequently: true,
});
/* effect */
let lastEffectWorkId: number | null = null;
let lastImageData: ImageData | null = null;
const onSync = () => {
if (typeof lastEffectWorkId === 'number')
cancelAnimationFrame(lastEffectWorkId);
lastEffectWorkId = requestAnimationFrame(() => {
// console.log('context', context);
if (!context) return;
const width = this.qualityRatio;
let height = Math.max(
Math.floor((blurCanvas.height / blurCanvas.width) * width),
1,
);
if (!Number.isFinite(height)) height = width;
if (!height) return;
context.globalAlpha = 1;
if (lastImageData) {
const frameOffset =
(1 / this.buffer) * (1000 / this.interpolationTime);
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
context.putImageData(lastImageData, 0, 0);
context.globalAlpha = frameOffset;
}
context.drawImage(video, 0, 0, width, height);
lastImageData = context.getImageData(0, 0, width, height); // current image data
lastEffectWorkId = null;
});
};
const applyVideoAttributes = () => {
const rect = video.getBoundingClientRect();
const newWidth = Math.floor(video.width || rect.width);
const newHeight = Math.floor(video.height || rect.height);
if (newWidth === 0 || newHeight === 0) return;
blurCanvas.width = this.qualityRatio;
blurCanvas.height = Math.floor(
(newHeight / newWidth) * this.qualityRatio,
);
blurCanvas.style.width = `${newWidth * this.sizeRatio}px`;
blurCanvas.style.height = `${newHeight * this.sizeRatio}px`;
if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
else blurCanvas.classList.remove('fullscreen');
const leftOffset = (newWidth * (this.sizeRatio - 1)) / 2;
const topOffset = (newHeight * (this.sizeRatio - 1)) / 2;
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
blurCanvas.style.setProperty('--blur', `${this.blur}px`);
blurCanvas.style.setProperty('--opacity', `${this.opacity}`);
};
this.update = applyVideoAttributes;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes') {
applyVideoAttributes();
}
});
});
const resizeObserver = new ResizeObserver(() => {
applyVideoAttributes();
});
/* hooking */
let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
applyVideoAttributes();
observer.observe(songVideo, { attributes: true });
resizeObserver.observe(songVideo);
window.addEventListener('resize', applyVideoAttributes);
const onPause = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = null;
};
const onPlay = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
};
songVideo.addEventListener('pause', onPause);
songVideo.addEventListener('play', onPlay);
/* injecting */
wrapper.prepend(blurCanvas);
/* cleanup */
return () => {
if (canvasInterval) clearInterval(canvasInterval);
songVideo.removeEventListener('pause', onPause);
songVideo.removeEventListener('play', onPlay);
observer.disconnect();
resizeObserver.disconnect();
window.removeEventListener('resize', applyVideoAttributes);
if (blurCanvas.isConnected) blurCanvas.remove();
};
};
const playerPage = document.querySelector<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
this.unregister?.();
this.unregister = injectBlurVideo() ?? null;
}
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen =
ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
this.unregister?.();
this.unregister = injectBlurVideo() ?? null;
} else {
this.unregister?.();
this.unregister = null;
}
}
}
});
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
},
onConfigChange(newConfig) {
this.interpolationTime = newConfig.interpolationTime;
this.buffer = newConfig.buffer;
this.qualityRatio = newConfig.quality;
this.sizeRatio = newConfig.size / 100;
this.blur = newConfig.blur;
this.opacity = newConfig.opacity;
this.isFullscreen = newConfig.fullscreen;
this.update?.();
},
stop() {
this.observer?.disconnect();
this.update = null;
this.unregister?.();
},
},
});

View File

@ -1,87 +0,0 @@
import config from './config';
import { MenuTemplate } from '../../menu';
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
const sizeList = [100, 110, 125, 150, 175, 200, 300];
const bufferList = [1, 5, 10, 20, 30];
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
export default (): MenuTemplate => [
{
label: 'Smoothness transition',
submenu: interpolationTimeList.map((interpolationTime) => ({
label: `During ${interpolationTime / 1000}s`,
type: 'radio',
checked: config.get('interpolationTime') === interpolationTime,
click() {
config.set('interpolationTime', interpolationTime);
},
})),
},
{
label: 'Quality',
submenu: qualityList.map((quality) => ({
label: `${quality} pixels`,
type: 'radio',
checked: config.get('quality') === quality,
click() {
config.set('quality', quality);
},
})),
},
{
label: 'Size',
submenu: sizeList.map((size) => ({
label: `${size}%`,
type: 'radio',
checked: config.get('size') === size,
click() {
config.set('size', size);
},
})),
},
{
label: 'Buffer',
submenu: bufferList.map((buffer) => ({
label: `${buffer}`,
type: 'radio',
checked: config.get('buffer') === buffer,
click() {
config.set('buffer', buffer);
},
})),
},
{
label: 'Opacity',
submenu: opacityList.map((opacity) => ({
label: `${opacity * 100}%`,
type: 'radio',
checked: config.get('opacity') === opacity,
click() {
config.set('opacity', opacity);
},
})),
},
{
label: 'Blur amount',
submenu: blurAmountList.map((blur) => ({
label: `${blur} pixels`,
type: 'radio',
checked: config.get('blur') === blur,
click() {
config.set('blur', blur);
},
})),
},
{
label: 'Using fullscreen',
type: 'checkbox',
checked: config.get('fullscreen'),
click(item) {
config.set('fullscreen', item.checked);
},
},
];

View File

@ -0,0 +1,26 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
export default createPlugin({
name: () => t('plugins.audio-compressor.name'),
description: () => t('plugins.audio-compressor.description'),
renderer() {
document.addEventListener(
'audioCanPlay',
({ detail: { audioSource, audioContext } }) => {
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
audioSource.connect(compressor);
compressor.connect(audioContext.destination);
},
{ once: true, passive: true },
);
},
});

View File

@ -1,17 +0,0 @@
export default () =>
document.addEventListener('audioCanPlay', (e) => {
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
}, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -1,9 +0,0 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -0,0 +1,11 @@
import { createPlugin } from '@/utils';
import style from './style.css?inline';
import { t } from '@/i18n';
export default createPlugin({
name: () => t('plugins.blur-nav-bar.name'),
description: () => t('plugins.blur-nav-bar.description'),
restartNeeded: true,
stylesheets: [style],
renderer() {},
});

View File

@ -1,4 +0,0 @@
export default async () => {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
await import('simple-youtube-age-restriction-bypass');
};

View File

@ -0,0 +1,13 @@
import { inject } from 'simple-youtube-age-restriction-bypass';
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
export default createPlugin({
name: () => t('plugins.bypass-age-restrictions.name'),
description: () => t('plugins.bypass-age-restrictions.description'),
restartNeeded: true,
// See https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass#userscript
renderer: () => inject(),
});

View File

@ -1,4 +1,3 @@
declare module 'simple-youtube-age-restriction-bypass' { declare module 'simple-youtube-age-restriction-bypass' {
const nothing: never; export const inject: () => void;
export default nothing;
} }

View File

@ -1,19 +1,33 @@
import { BrowserWindow, ipcMain } from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '@/providers/prompt-options';
import { createBackend } from '@/utils';
import { t } from '@/i18n';
export default (win: BrowserWindow) => { export default createBackend({
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt( start({ ipc: { handle }, window }) {
{ handle(
title: 'Choose Caption', 'captionsSelector',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, async (captionLabels: Record<string, string>, currentIndex: string) =>
type: 'select', await prompt(
value: currentIndex, {
selectOptions: captionLabels, title: t('plugins.captions-selector.prompt.selector.title'),
resizable: true, label: t('plugins.captions-selector.prompt.selector.label', {
...promptOptions(), language:
}, captionLabels[currentIndex] ||
win, t('plugins.captions-selector.prompt.selector.none'),
)); }),
}; type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
window,
),
);
},
stop({ ipc: { removeHandler } }) {
removeHandler('captionsSelector');
},
});

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('captions-selector', { enableFront: true });
export default config;

View File

@ -1,101 +0,0 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import configProvider from './config';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html';
import { ElementFromHtml } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
interface LanguageOptions {
displayName: string;
id: string | null;
is_default: boolean;
is_servable: boolean;
is_translateable: boolean;
kind: string;
languageCode: string; // 2 length
languageName: string;
name: string | null;
vss_id: string;
}
let config: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
export default async () => {
// RENDERER
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
function setup(api: YoutubePlayer) {
$('.right-controls-buttons').append(captionsSettingsButton);
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', () => {
if (config.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none';
return;
}
api.loadModule('captions');
setTimeout(() => {
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.addEventListener('click', async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
let currentIndex = currentCaptionTrack
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
: null;
const captionLabels = [
...captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
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,55 @@
import { createPlugin } from '@/utils';
import { YoutubePlayer } from '@/types/youtube-player';
import backend from './back';
import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
import { t } from '@/i18n';
export default createPlugin<
unknown,
unknown,
{
captionsSettingsButton: HTMLElement;
captionTrackList: LanguageOptions[] | null;
api: YoutubePlayer | null;
config: CaptionsSelectorConfig | null;
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
videoChangeListener: () => void;
captionsButtonClickListener: () => void;
},
CaptionsSelectorConfig
>({
name: () => t('plugins.captions-selector.name'),
description: () => t('plugins.captions-selector.description'),
config: {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
async menu({ getConfig, setConfig }) {
const config = await getConfig();
return [
{
label: t('plugins.captions-selector.menu.autoload'),
type: 'checkbox',
checked: config.autoload as boolean,
click(item) {
setConfig({ autoload: item.checked });
},
},
{
label: t('plugins.captions-selector.menu.disable-captions'),
type: 'checkbox',
checked: config.disableCaptions as boolean,
click(item) {
setConfig({ disableCaptions: item.checked });
},
},
];
},
backend,
renderer,
});

View File

@ -1,22 +0,0 @@
import config from './config';
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{
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('disableCaptions'),
click(item) {
config.set('disableCaptions', item.checked);
},
},
];

View File

@ -0,0 +1,151 @@
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { createRenderer } from '@/utils';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
import { YoutubePlayer } from '@/types/youtube-player';
export interface LanguageOptions {
displayName: string;
id: string | null;
is_default: boolean;
is_servable: boolean;
is_translateable: boolean;
kind: string;
languageCode: string; // 2 length
languageName: string;
name: string | null;
vss_id: string;
}
export interface CaptionsSelectorConfig {
enabled: boolean;
disableCaptions: boolean;
autoload: boolean;
lastCaptionsCode: string;
}
export default createRenderer<
{
captionsSettingsButton: HTMLElement;
captionTrackList: LanguageOptions[] | null;
api: YoutubePlayer | null;
config: CaptionsSelectorConfig | null;
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
videoChangeListener: () => void;
captionsButtonClickListener: () => void;
},
CaptionsSelectorConfig
>({
captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML),
captionTrackList: null,
api: null,
config: null,
setConfig: () => {},
async captionsButtonClickListener() {
if (this.captionTrackList?.length) {
const currentCaptionTrack = this.api!.getOption<LanguageOptions>(
'captions',
'track',
);
let currentIndex = currentCaptionTrack
? this.captionTrackList.indexOf(
this.captionTrackList.find(
(track) =>
track.languageCode === currentCaptionTrack.languageCode,
)!,
)
: null;
const captionLabels = [
...this.captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = (await window.ipcRenderer.invoke(
'captionsSelector',
captionLabels,
currentIndex,
)) as number;
if (currentIndex === null) {
return;
}
const newCaptions = this.captionTrackList[currentIndex];
this.setConfig({ lastCaptionsCode: newCaptions?.languageCode });
if (newCaptions) {
this.api?.setOption('captions', 'track', {
languageCode: newCaptions.languageCode,
});
} else {
this.api?.setOption('captions', 'track', {});
}
setTimeout(() => this.api?.playVideo());
}
},
videoChangeListener() {
if (this.config?.disableCaptions) {
setTimeout(() => this.api!.unloadModule('captions'), 100);
this.captionsSettingsButton.style.display = 'none';
return;
}
this.api!.loadModule('captions');
setTimeout(() => {
this.captionTrackList =
this.api!.getOption('captions', 'tracklist') ?? [];
if (this.config!.autoload && this.config!.lastCaptionsCode) {
this.api?.setOption('captions', 'track', {
languageCode: this.config!.lastCaptionsCode,
});
}
this.captionsSettingsButton.style.display = this.captionTrackList?.length
? 'inline-block'
: 'none';
}, 250);
},
async start({ getConfig, setConfig }) {
this.config = await getConfig();
this.setConfig = setConfig;
},
stop() {
document
.querySelector('.right-controls-buttons')
?.removeChild(this.captionsSettingsButton);
document
.querySelector<YoutubePlayer & HTMLElement>('#movie_player')
?.unloadModule('captions');
document
.querySelector('video')
?.removeEventListener('srcChanged', this.videoChangeListener);
this.captionsSettingsButton.removeEventListener(
'click',
this.captionsButtonClickListener,
);
},
onPlayerApiReady(playerApi) {
this.api = playerApi;
document
.querySelector('.right-controls-buttons')
?.append(this.captionsSettingsButton);
this.captionTrackList =
this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
document
.querySelector('video')
?.addEventListener('srcChanged', this.videoChangeListener);
this.captionsSettingsButton.addEventListener(
'click',
this.captionsButtonClickListener,
);
},
onConfigChange(newConfig) {
this.config = newConfig;
},
});

View File

@ -1,16 +1,25 @@
<tp-yt-paper-icon-button aria-disabled="false" aria-label="Open captions selector" <tp-yt-paper-icon-button
class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles" aria-disabled="false"
role="button" tabindex="0" aria-label="Open captions selector"
title="Open captions selector"> class="player-captions-button style-scope ytmusic-player"
icon="yt-icons:subtitles"
role="button"
tabindex="0"
title="Open captions selector"
>
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon"> <tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
<svg class="style-scope yt-icon" <svg
focusable="false" preserveAspectRatio="xMidYMid meet" class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%;" focusable="false"
viewBox="0 0 24 24"> preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon"> <g class="style-scope yt-icon">
<path <path
class="style-scope tp-yt-iron-icon" class="style-scope tp-yt-iron-icon"
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"></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"
></path>
</g> </g>
</svg> </svg>
</tp-yt-iron-icon> </tp-yt-iron-icon>

View File

@ -1,10 +0,0 @@
export default () => {
const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled
= compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none';
if (isCompactSidebarDisabled) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
};

View File

@ -0,0 +1,43 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
export default createPlugin<
unknown,
unknown,
{
getCompactSidebar: () => HTMLElement | null;
isCompactSidebarDisabled: () => boolean;
}
>({
name: () => t('plugins.compact-sidebar.name'),
description: () => t('plugins.compact-sidebar.description'),
restartNeeded: false,
config: {
enabled: false,
},
renderer: {
getCompactSidebar: () => document.querySelector('#mini-guide'),
isCompactSidebarDisabled() {
const compactSidebar = this.getCompactSidebar();
return (
compactSidebar === null ||
window.getComputedStyle(compactSidebar).display === 'none'
);
},
start() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
stop() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
onConfigChange() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
},
});

View File

@ -1,11 +0,0 @@
import { ipcMain } from 'electron';
import { Innertube } from 'youtubei.js';
export default async () => {
const yt = await Innertube.create();
ipcMain.handle('audio-url', async (_, videoID: string) => {
const info = await yt.getBasicInfo(videoID);
return info.streaming_data?.formats[0].decipher(yt.session.player);
});
};

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('crossfade', { enableFront: true });
export default config;

View File

@ -15,21 +15,21 @@
* v0.2.0, 07/2016 * v0.2.0, 07/2016
*/ */
'use strict';
// Internal utility: check if value is a valid volume level and throw if not // Internal utility: check if value is a valid volume level and throw if not
const validateVolumeLevel = (value: number) => { const validateVolumeLevel = (value: number) => {
// Number between 0 and 1? // Number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) { if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// Yup, that's fine // Yup, that's fine
} else { } else {
// Abort and throw an exception // Abort and throw an exception
throw new TypeError('Number between 0 and 1 expected as volume!'); throw new TypeError('Number between 0 and 1 expected as volume!');
} }
}; };
type VolumeLogger = <Params extends unknown[]>(message: string, ...args: Params) => void; type VolumeLogger = <Params extends unknown[]>(
message: string,
...args: Params
) => void;
interface VolumeFaderOptions { interface VolumeFaderOptions {
/** /**
* logging `function(stuff, …)` for execution information (default: no logging) * logging `function(stuff, …)` for execution information (default: no logging)
@ -73,7 +73,6 @@ export class VolumeFader {
private active: boolean = false; private active: boolean = false;
private fade: VolumeFade | undefined; private fade: VolumeFade | undefined;
/** /**
* VolumeFader Constructor * VolumeFader Constructor
* *
@ -121,17 +120,17 @@ export class VolumeFader {
// Default dynamic range? // Default dynamic range?
if ( if (
options.fadeScaling === undefined options.fadeScaling === undefined ||
|| options.fadeScaling === 'logarithmic' options.fadeScaling === 'logarithmic'
) { ) {
// Set default of 60 dB // Set default of 60 dB
dynamicRange = 3; dynamicRange = 3;
} }
// Custom dynamic range? // Custom dynamic range?
else if ( else if (
typeof options.fadeScaling === 'number' typeof options.fadeScaling === 'number' &&
&& !Number.isNaN(options.fadeScaling) !Number.isNaN(options.fadeScaling) &&
&& options.fadeScaling > 0 options.fadeScaling > 0
) { ) {
// Turn amplitude dB into a multiple of 10 power dB // Turn amplitude dB into a multiple of 10 power dB
dynamicRange = options.fadeScaling / 2 / 10; dynamicRange = options.fadeScaling / 2 / 10;
@ -153,13 +152,13 @@ export class VolumeFader {
}; };
// Log setting if not default // Log setting if not default
options.fadeScaling options.fadeScaling &&
&& this.logger this.logger &&
&& this.logger( this.logger(
'Using logarithmic fading with ' 'Using logarithmic fading with ' +
+ String(10 * dynamicRange) String(10 * dynamicRange) +
+ ' dB dynamic range.', ' dB dynamic range.',
); );
} }
// Set initial volume? // Set initial volume?
@ -171,10 +170,8 @@ export class VolumeFader {
this.media.volume = options.initialVolume; this.media.volume = options.initialVolume;
// Log setting // Log setting
this.logger this.logger &&
&& this.logger( this.logger('Set initial volume to ' + String(this.media.volume) + '.');
'Set initial volume to ' + String(this.media.volume) + '.',
);
} }
// Fade duration given? // Fade duration given?
@ -239,8 +236,8 @@ export class VolumeFader {
this.fadeDuration = fadeDuration; this.fadeDuration = fadeDuration;
// Log setting // Log setting
this.logger this.logger &&
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.'); this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
} else { } else {
// Abort and throw an exception // Abort and throw an exception
throw new TypeError('Positive number expected as fade duration!'); throw new TypeError('Positive number expected as fade duration!');
@ -310,13 +307,14 @@ export class VolumeFader {
// Time left for fading? // Time left for fading?
if (now < this.fade.time.end) { if (now < this.fade.time.end) {
// Compute current fade progress // Compute current fade progress
const progress const progress =
= (now - this.fade.time.start) (now - this.fade.time.start) /
/ (this.fade.time.end - this.fade.time.start); (this.fade.time.end - this.fade.time.start);
// Compute current level on internal scale // Compute current level on internal scale
const level const level =
= (progress * (this.fade.volume.end - this.fade.volume.start)) + this.fade.volume.start; (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 // Map fade level to volume level and apply it to media element
this.media.volume = this.scale.internalToVolume(level); this.media.volume = this.scale.internalToVolume(level);
@ -325,10 +323,8 @@ export class VolumeFader {
window.requestAnimationFrame(this.updateVolume.bind(this)); window.requestAnimationFrame(this.updateVolume.bind(this));
} else { } else {
// Log end of fade // Log end of fade
this.logger this.logger &&
&& this.logger( this.logger('Fade to ' + String(this.fade.volume.end) + ' complete.');
'Fade to ' + String(this.fade.volume.end) + ' complete.',
);
// Time is up, jump to target volume // Time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end); this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
@ -391,5 +387,5 @@ export class VolumeFader {
} }
export default { export default {
VolumeFader VolumeFader,
}; };

View File

@ -1,164 +0,0 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import { Howl } from 'howler';
// Extracted from https://github.com/bitfasching/VolumeFader
import { VolumeFader } from './fader';
import configProvider from './config';
import defaultConfigs from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic';
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition: Promise<unknown>;
const defaultConfig = defaultConfigs.plugins.crossfade;
let config: ConfigType<'crossfade'>;
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(config[key]) || (defaultConfig[key] as number);
const getStreamURL = async (videoID: string) => ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL(
(event.currentTarget as Navigation).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 = (url: string) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeInDuration'),
});
transitionAudio.play();
transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
});
video.addEventListener('pause', () => {
transitionAudio.pause();
});
video.addEventListener('play', () => {
transitionAudio.play();
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 - configGetNumber('secondsBeforeEnd')
&& isReadyToCrossfade()
) {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector<HTMLButtonElement>('.next-button')?.click();
}
};
video.addEventListener('timeupdate', transitionBeforeEnd);
};
const onApiLoaded = () => {
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
await createAudioForCrossfade(url);
});
};
const crossfade = (cb: () => void) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition: () => void;
waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve;
});
const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeOutDuration'),
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
export default async () => {
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', onApiLoaded, {
once: true,
passive: true,
});
};

View File

@ -0,0 +1,325 @@
import { Innertube } from 'youtubei.js';
import { BrowserWindow } from 'electron';
import prompt from 'custom-electron-prompt';
import { Howl } from 'howler';
import promptOptions from '@/providers/prompt-options';
import { getNetFetchAsFetch } from '@/plugins/utils/main';
import { createPlugin } from '@/utils';
import { VolumeFader } from './fader';
import { t } from '@/i18n';
import type { RendererContext } from '@/types/contexts';
export type CrossfadePluginConfig = {
enabled: boolean;
fadeInDuration: number;
fadeOutDuration: number;
secondsBeforeEnd: number;
fadeScaling: 'linear' | 'logarithmic' | number;
};
export default createPlugin<
unknown,
unknown,
{
config: CrossfadePluginConfig | null;
ipc: RendererContext<CrossfadePluginConfig>['ipc'] | null;
},
CrossfadePluginConfig
>({
name: () => t('plugins.crossfade.name'),
description: () => t('plugins.crossfade.description'),
restartNeeded: true,
config: {
enabled: false,
/**
* The duration of the fade in and fade out in milliseconds.
*
* @default 1500ms
*/
fadeInDuration: 1500,
/**
* The duration of the fade in and fade out in milliseconds.
*
* @default 5000ms
*/
fadeOutDuration: 5000,
/**
* The duration of the fade in and fade out in seconds.
*
* @default 10s
*/
secondsBeforeEnd: 10,
/**
* The scaling algorithm to use for the fade.
* (or a positive number in dB)
*
* @default 'linear'
*/
fadeScaling: 'linear',
},
menu({ window, getConfig, setConfig }) {
const promptCrossfadeValues = async (
win: BrowserWindow,
options: CrossfadePluginConfig,
): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
const res = await prompt(
{
title: t('plugins.crossfade.prompt.options'),
type: 'multiInput',
multiInputOptions: [
{
label: t(
'plugins.crossfade.prompt.options.multi-input.fade-in-duration',
),
value: options.fadeInDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: t(
'plugins.crossfade.prompt.options.multi-input.fade-out-duration',
),
value: options.fadeOutDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: t(
'plugins.crossfade.prompt.options.multi-input.seconds-before-end',
),
value: options.secondsBeforeEnd,
inputAttrs: {
type: 'number',
required: true,
min: '0',
},
},
{
label: t(
'plugins.crossfade.prompt.options.multi-input.fade-scaling.label',
),
selectOptions: {
linear: t(
'plugins.crossfade.prompt.options.multi-input.fade-scaling.linear',
),
logarithmic: t(
'plugins.crossfade.prompt.options.multi-input.fade-scaling.logarithmic',
),
},
value: options.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
if (!res) {
return undefined;
}
let fadeScaling: 'linear' | 'logarithmic' | number;
if (res[3] === 'linear' || res[3] === 'logarithmic') {
fadeScaling = res[3];
} else if (isFinite(Number(res[3]))) {
fadeScaling = Number(res[3]);
} else {
fadeScaling = options.fadeScaling;
}
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling,
};
};
return [
{
label: t('plugins.crossfade.menu.advanced'),
async click() {
const newOptions = await promptCrossfadeValues(
window,
await getConfig(),
);
if (newOptions) {
setConfig(newOptions);
}
},
},
];
},
async backend({ ipc }) {
const yt = await Innertube.create({
fetch: getNetFetchAsFetch(),
});
ipc.handle('audio-url', async (videoID: string) => {
const info = await yt.getBasicInfo(videoID);
return info.streaming_data?.formats[0].decipher(yt.session.player);
});
},
renderer: {
config: null,
ipc: null,
start({ ipc }) {
this.ipc = ipc;
},
onConfigChange(newConfig) {
this.config = newConfig;
},
onPlayerApiReady() {
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition: Promise<unknown>;
const getStreamURL = async (videoID: string): Promise<string> =>
this.ipc?.invoke('audio-url', videoID);
const getVideoIDFromURL = (url: string) =>
new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () =>
transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL(
(event.currentTarget as Navigation).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 = (url: string) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, {
fadeScaling: this.config?.fadeScaling,
fadeDuration: this.config?.fadeInDuration,
});
transitionAudio.play();
transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
});
video.addEventListener('pause', () => {
transitionAudio.pause();
});
video.addEventListener('play', () => {
transitionAudio.play();
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 - this.config!.secondsBeforeEnd &&
isReadyToCrossfade()
) {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector<HTMLButtonElement>('.next-button')?.click();
}
};
video.addEventListener('timeupdate', transitionBeforeEnd);
};
const crossfade = (cb: () => void) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition: () => void;
waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve;
});
const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: this.config?.fadeScaling,
fadeDuration: this.config?.fadeOutDuration,
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
createAudioForCrossfade(url);
});
},
},
});

View File

@ -1,86 +0,0 @@
import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron';
import config from './config';
import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow): MenuTemplate => [
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) {
config.setAll(newOptions);
}
},
},
];
async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
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,23 +0,0 @@
import type { ConfigType } from '../../config/dynamic';
export default (options: ConfigType<'disable-autoplay'>) => {
const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
}
};
document.addEventListener('apiLoaded', (apiEvent) => {
const eventListener = (name: string) => {
if (options.applyOnce) {
apiEvent.detail.removeEventListener('videodatachange', eventListener);
}
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
}
};
apiEvent.detail.addEventListener('videodatachange', eventListener);
}, { once: true, passive: true });
};

View File

@ -0,0 +1,85 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import type { VideoDataChanged } from '@/types/video-data-changed';
import type { YoutubePlayer } from '@/types/youtube-player';
export type DisableAutoPlayPluginConfig = {
enabled: boolean;
applyOnce: boolean;
};
export default createPlugin<
unknown,
unknown,
{
config: DisableAutoPlayPluginConfig | null;
api: YoutubePlayer | null;
eventListener: (event: CustomEvent<VideoDataChanged>) => void;
timeUpdateListener: (e: Event) => void;
},
DisableAutoPlayPluginConfig
>({
name: () => t('plugins.disable-autoplay.name'),
description: () => t('plugins.disable-autoplay.description'),
restartNeeded: false,
config: {
enabled: false,
applyOnce: false,
},
menu: async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: t('plugins.disable-autoplay.menu.apply-once'),
type: 'checkbox',
checked: config.applyOnce,
async click() {
const nowConfig = await getConfig();
setConfig({
applyOnce: !nowConfig.applyOnce,
});
},
},
];
},
renderer: {
config: null,
api: null,
eventListener(event: CustomEvent<VideoDataChanged>) {
if (this.config?.applyOnce) {
document.removeEventListener('videodatachange', this.eventListener);
}
if (event.detail.name === 'dataloaded') {
this.api?.pauseVideo();
document
.querySelector<HTMLVideoElement>('video')
?.addEventListener('timeupdate', this.timeUpdateListener, {
once: true,
});
}
},
timeUpdateListener(e: Event) {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
}
},
async start({ getConfig }) {
this.config = await getConfig();
},
onPlayerApiReady(api) {
this.api = api;
document.addEventListener('videodatachange', this.eventListener);
},
stop() {
document.removeEventListener('videodatachange', this.eventListener);
},
onConfigChange(newConfig) {
this.config = newConfig;
},
},
});

View File

@ -1,20 +0,0 @@
import { BrowserWindow } from 'electron';
import { setMenuOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
{
label: 'Applies only on startup',
type: 'checkbox',
checked: options.applyOnce,
click() {
setMenuOptions('disable-autoplay', {
applyOnce: !options.applyOnce,
});
}
}
];

View File

@ -1,228 +0,0 @@
import { app, dialog, ipcMain } from 'electron';
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic';
// Application ID registered by @Zo-Bro-23
const clientId = '1043858434585526382';
export interface Info {
rpc: DiscordClient;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: SongInfo;
}
const info: Info = {
rpc: new DiscordClient({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: undefined,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks: (() => void)[] = [];
const resetInfo = () => {
info.ready = false;
clearTimeout(clearActivity);
if (dev()) {
console.log('discord disconnected');
}
for (const cb of refreshCallbacks) {
cb();
}
};
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: Electron.BrowserWindow;
export const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log('Attempted to connect with active connection');
}
return;
}
info.ready = false;
// Startup the rpc client
info.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (info.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(window, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
let clearActivity: NodeJS.Timeout | undefined;
let updateActivity: SongInfoCallback;
type DiscordOptions = ConfigType<'discord'>;
export default (
win: Electron.BrowserWindow,
options: DiscordOptions,
) => {
info.rpc.on('connected', () => {
if (dev()) {
console.log('discord connected');
}
for (const cb of refreshCallbacks) {
cb();
}
});
info.rpc.on('ready', () => {
info.ready = true;
if (info.lastSongInfo) {
updateActivity(info.lastSongInfo);
}
});
info.rpc.on('disconnected', () => {
resetInfo();
if (info.autoReconnect) {
connectTimeout();
}
});
info.autoReconnect = options.autoReconnect;
window = win;
// We get multiple events
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
// Skip time: PAUSE(N), PLAY(N)
updateActivity = (songInfo) => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
// Stop the clear activity timout
clearTimeout(clearActivity);
// Stop early if discord connection is not ready
// do this after clearTimeout to avoid unexpected clears
if (!info.rpc || !info.ready) {
return;
}
// Clear directly if timeout is 0
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
info.rpc.user?.clearActivity().catch(console.error);
return;
}
// Song information changed, so lets update the rich presence
// @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 hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
if (songInfo.title.length < 2) {
songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
}
if (songInfo.artist.length < 2) {
songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
}
const activityInfo: SetActivity = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: [
...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
],
};
if (songInfo.isPaused) {
// Add a paused icon to show that the song is paused
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled
if (options.activityTimoutEnabled) {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
}
} else if (!options.hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp
= songStartTime + (songInfo.songDuration * 1000);
}
info.rpc.user?.setActivity(activityInfo).catch(console.error);
};
// If the page is ready, register the callback
win.once('ready-to-show', () => {
let lastSongInfo: SongInfo;
registerCallback((songInfo) => {
lastSongInfo = songInfo;
updateActivity(songInfo);
});
connect();
let lastSent = Date.now();
ipcMain.on('timeChanged', (_, t: number) => {
const currentTime = Date.now();
// if lastSent is more than 5 seconds ago, send the new time
if (currentTime - lastSent > 5000) {
lastSent = currentTime;
lastSongInfo.elapsedSeconds = t;
updateActivity(lastSongInfo);
}
});
});
app.on('window-all-closed', clear);
};
export const clear = () => {
if (info.rpc) {
info.rpc.user?.clearActivity();
}
clearTimeout(clearActivity);
};
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const isConnected = () => info.rpc !== null;

View File

@ -0,0 +1,53 @@
import { createPlugin } from '@/utils';
import { backend } from './main';
import { onMenu } from './menu';
import { t } from '@/i18n';
export type DiscordPluginConfig = {
enabled: boolean;
/**
* If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
*
* @default true
*/
autoReconnect: boolean;
/**
* If enabled, the discord rich presence gets cleared when music paused after the time specified below
*/
activityTimeoutEnabled: boolean;
/**
* The time in milliseconds after which the discord rich presence gets cleared when music paused
*
* @default 10 * 60 * 1000 (10 minutes)
*/
activityTimeoutTime: number;
/**
* Add a "Play on YouTube Music" button to rich presence
*/
playOnYouTubeMusic: boolean;
/**
* Hide the "View App On GitHub" button in the rich presence
*/
hideGitHubButton: boolean;
/**
* Hide the "duration left" in the rich presence
*/
hideDurationLeft: boolean;
};
export default createPlugin({
name: () => t('plugins.discord.name'),
description: () => t('plugins.discord.description'),
restartNeeded: false,
config: {
enabled: false,
autoReconnect: true,
activityTimeoutEnabled: true,
activityTimeoutTime: 10 * 60 * 1000,
playOnYouTubeMusic: true,
hideGitHubButton: false,
hideDurationLeft: false,
} as DiscordPluginConfig,
menu: onMenu,
backend,
});

271
src/plugins/discord/main.ts Normal file
View File

@ -0,0 +1,271 @@
import { app, dialog, ipcMain } from 'electron';
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback, { type SongInfo } from '@/providers/song-info';
import { createBackend, LoggerPrefix } from '@/utils';
import { t } from '@/i18n';
import type { DiscordPluginConfig } from './index';
// Application ID registered by @th-ch/youtube-music dev team
const clientId = '1177081335727267940';
export interface Info {
rpc: DiscordClient;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: SongInfo;
}
const info: Info = {
rpc: new DiscordClient({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: undefined,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks: (() => void)[] = [];
const resetInfo = () => {
info.ready = false;
clearTimeout(clearActivity);
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.disconnected'));
}
for (const cb of refreshCallbacks) {
cb();
}
};
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: Electron.BrowserWindow;
export const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.already-connected'));
}
return;
}
info.ready = false;
// Startup the rpc client
info.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (info.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(window, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
let clearActivity: NodeJS.Timeout | undefined;
export const clear = () => {
if (info.rpc) {
info.rpc.user?.clearActivity();
}
clearTimeout(clearActivity);
};
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const isConnected = () => info.rpc !== null;
export const backend = createBackend<
{
config?: DiscordPluginConfig;
updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void;
},
DiscordPluginConfig
>({
/**
* We get multiple events
* Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
* Skip time: PAUSE(N), PLAY(N)
*/
updateActivity: (songInfo, config) => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
// Stop the clear activity timeout
clearTimeout(clearActivity);
// Stop early if discord connection is not ready
// do this after clearTimeout to avoid unexpected clears
if (!info.rpc || !info.ready) {
return;
}
// Clear directly if timeout is 0
if (
songInfo.isPaused &&
config.activityTimeoutEnabled &&
config.activityTimeoutTime === 0
) {
info.rpc.user?.clearActivity().catch(console.error);
return;
}
// Song information changed, so lets update the rich presence
// @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 hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
if (songInfo.title.length < 2) {
songInfo.title += hangulFillerUnicodeCharacter.repeat(
2 - songInfo.title.length,
);
}
if (songInfo.artist.length < 2) {
songInfo.artist += hangulFillerUnicodeCharacter.repeat(
2 - songInfo.title.length,
);
}
const activityInfo: SetActivity = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: [
...(config.playOnYouTubeMusic
? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }]
: []),
...(config.hideGitHubButton
? []
: [
{
label: 'View App On GitHub',
url: 'https://github.com/th-ch/youtube-music',
},
]),
],
};
if (songInfo.isPaused) {
// Add a paused icon to show that the song is paused
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled
if (config.activityTimeoutEnabled) {
clearActivity = setTimeout(
() => info.rpc.user?.clearActivity().catch(console.error),
config.activityTimeoutTime ?? 10_000,
);
}
} else if (!config.hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + (songInfo.songDuration * 1000);
}
info.rpc.user?.setActivity(activityInfo).catch(console.error);
},
async start({ window: win, getConfig }) {
this.config = await getConfig();
info.rpc.on('connected', () => {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
}
for (const cb of refreshCallbacks) {
cb();
}
});
info.rpc.on('ready', () => {
info.ready = true;
if (info.lastSongInfo && this.config) {
this.updateActivity(info.lastSongInfo, this.config);
}
});
info.rpc.on('disconnected', () => {
resetInfo();
if (info.autoReconnect) {
connectTimeout();
}
});
info.autoReconnect = this.config.autoReconnect;
window = win;
// If the page is ready, register the callback
win.once('ready-to-show', () => {
let lastSongInfo: SongInfo;
registerCallback((songInfo) => {
lastSongInfo = songInfo;
if (this.config) this.updateActivity(songInfo, this.config);
});
connect();
let lastSent = Date.now();
ipcMain.on('timeChanged', (_, t: number) => {
const currentTime = Date.now();
// if lastSent is more than 5 seconds ago, send the new time
if (currentTime - lastSent > 5000) {
lastSent = currentTime;
if (lastSongInfo) {
lastSongInfo.elapsedSeconds = t;
if (this.config) this.updateActivity(lastSongInfo, this.config);
}
}
});
});
app.on('window-all-closed', clear);
},
stop() {
resetInfo();
},
onConfigChange(newConfig) {
this.config = newConfig;
info.autoReconnect = newConfig.autoReconnect;
if (info.lastSongInfo) {
this.updateActivity(info.lastSongInfo, newConfig);
}
},
});

View File

@ -1,98 +1,119 @@
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './back'; import { clear, connect, isConnected, registerRefresh } from './main';
import { setMenuOptions } from '../../config/plugins'; import { singleton } from '@/providers/decorators';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '@/providers/prompt-options';
import { singleton } from '../../providers/decorators'; import { setMenuOptions } from '@/config/plugins';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import { t } from '@/i18n';
import type { MenuContext } from '@/types/contexts';
import type { DiscordPluginConfig } from './index';
import type { MenuTemplate } from '@/menu';
const registerRefreshOnce = singleton((refreshMenu: () => void) => { const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu); registerRefresh(refreshMenu);
}); });
type DiscordOptions = ConfigType<'discord'>; export const onMenu = async ({
window,
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => { getConfig,
registerRefreshOnce(refreshMenu); setConfig,
refresh,
}: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig();
registerRefreshOnce(refresh);
return [ return [
{ {
label: isConnected() ? 'Connected' : 'Reconnect', label: isConnected()
? t('plugins.discord.menu.connected')
: t('plugins.discord.menu.disconnected'),
enabled: !isConnected(), enabled: !isConnected(),
click: () => connect(), click: () => connect(),
}, },
{ {
label: 'Auto reconnect', label: t('plugins.discord.menu.auto-reconnect'),
type: 'checkbox', type: 'checkbox',
checked: options.autoReconnect, checked: config.autoReconnect,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.autoReconnect = item.checked; setConfig({
setMenuOptions('discord', options); autoReconnect: item.checked,
});
}, },
}, },
{ {
label: 'Clear activity', label: t('plugins.discord.menu.clear-activity'),
click: clear, click: clear,
}, },
{ {
label: 'Clear activity after timeout', label: t('plugins.discord.menu.clear-activity-after-timeout'),
type: 'checkbox', type: 'checkbox',
checked: options.activityTimoutEnabled, checked: config.activityTimeoutEnabled,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.activityTimoutEnabled = item.checked; setConfig({
setMenuOptions('discord', options); activityTimeoutEnabled: item.checked,
});
}, },
}, },
{ {
label: 'Play on YouTube Music', label: t('plugins.discord.menu.play-on-youtube-music'),
type: 'checkbox', type: 'checkbox',
checked: options.playOnYouTubeMusic, checked: config.playOnYouTubeMusic,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.playOnYouTubeMusic = item.checked; setConfig({
setMenuOptions('discord', options); playOnYouTubeMusic: item.checked,
});
}, },
}, },
{ {
label: 'Hide GitHub link Button', label: t('plugins.discord.menu.hide-github-button'),
type: 'checkbox', type: 'checkbox',
checked: options.hideGitHubButton, checked: config.hideGitHubButton,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.hideGitHubButton = item.checked; setConfig({
setMenuOptions('discord', options); hideGitHubButton: item.checked,
});
}, },
}, },
{ {
label: 'Hide duration left', label: t('plugins.discord.menu.hide-duration-left'),
type: 'checkbox', type: 'checkbox',
checked: options.hideDurationLeft, checked: config.hideDurationLeft,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.hideDurationLeft = item.checked; setConfig({
setMenuOptions('discord', options); hideGitHubButton: item.checked,
});
}, },
}, },
{ {
label: 'Set inactivity timeout', label: t('plugins.discord.menu.set-inactivity-timeout'),
click: () => setInactivityTimeout(win, options), click: () => setInactivityTimeout(window, config),
}, },
]; ];
}; };
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) { async function setInactivityTimeout(
const output = await prompt({ win: Electron.BrowserWindow,
title: 'Set Inactivity Timeout', options: DiscordPluginConfig,
label: 'Enter inactivity timeout in seconds:', ) {
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)), const output = await prompt(
type: 'counter', {
counterOptions: { minimum: 0, multiFire: true }, title: t('plugins.discord.prompt.set-inactivity-timeout.title'),
width: 450, label: t('plugins.discord.prompt.set-inactivity-timeout.label'),
...promptOptions(), value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)),
}, win); type: 'counter',
counterOptions: { minimum: 0, multiFire: true },
width: 450,
...promptOptions(),
},
win,
);
if (output) { if (output) {
options.activityTimoutTime = Math.round(~~output * 1e3); options.activityTimeoutTime = Math.round(~~output * 1e3);
setMenuOptions('discord', options); setMenuOptions('discord', options);
} }
} }

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('downloader');
export default config;

View File

@ -1,83 +0,0 @@
import { ipcRenderer } from 'electron';
import downloadHTML from './templates/download.html';
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { getSongInfo } from '../../providers/song-info-front';
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
if (menu.contains(downloadButton)) {
return;
}
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => doneFirstLoad ||= true, 500);
});
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
(global as any).download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
ipcRenderer.send('download-song', videoUrl);
};
export default () => {
document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
}, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn('Cannot update progress');
}
});
};

View File

@ -0,0 +1,44 @@
import { DefaultPresetList, Preset } from './types';
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { onConfigChange, onMainLoad } from './main';
import { onPlayerApiReady, onRendererLoad } from './renderer';
import { onMenu } from './menu';
import { t } from '@/i18n';
export type DownloaderPluginConfig = {
enabled: boolean;
downloadFolder?: string;
selectedPreset: string;
customPresetSetting: Preset;
skipExisting: boolean;
playlistMaxItems?: number;
};
export const defaultConfig: DownloaderPluginConfig = {
enabled: false,
downloadFolder: undefined,
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined,
};
export default createPlugin({
name: () => t('plugins.downloader.name'),
description: () => t('plugins.downloader.description'),
restartNeeded: true,
config: defaultConfig,
stylesheets: [style],
menu: onMenu,
backend: {
start: onMainLoad,
onConfigChange,
},
renderer: {
start: onRendererLoad,
onPlayerApiReady,
},
});

View File

@ -7,7 +7,7 @@ import {
import { join } from 'node:path'; import { join } from 'node:path';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron'; import { app, BrowserWindow, dialog } from 'electron';
import { import {
ClientType, ClientType,
Innertube, Innertube,
@ -27,16 +27,20 @@ import {
sendFeedback as sendFeedback_, sendFeedback as sendFeedback_,
setBadge, setBadge,
} from './utils'; } from './utils';
import config from './config';
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
import style from './style.css'; import { fetchFromGenius } from '@/plugins/lyrics-genius/main';
import { isEnabled } from '@/config/plugins';
import { cleanupName, getImage, SongInfo } from '@/providers/song-info';
import { getNetFetchAsFetch } from '@/plugins/utils/main';
import { cache } from '@/providers/decorators';
import { fetchFromGenius } from '../lyrics-genius/back'; import { t } from '@/i18n';
import { isEnabled } from '../../config/plugins';
import { cleanupName, getImage, SongInfo } from '../../providers/song-info'; import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
import { injectCSS } from '../utils';
import { cache } from '../../providers/decorators'; import type { DownloaderPluginConfig } from '../index';
import type { BackendContext } from '@/types/contexts';
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
@ -44,7 +48,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube'; import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo'; import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
import type { GetPlayerResponse } from '../../types/get-player-response'; import type { GetPlayerResponse } from '@/types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string }; type CustomSongInfo = SongInfo & { trackId?: string };
@ -68,12 +72,13 @@ const sendError = (error: Error, source?: string) => {
const cause = error.cause ? `\n\n${String(error.cause)}` : ''; const cause = error.cause ? `\n\n${String(error.cause)}` : '';
const message = `${error.toString()}${songNameMessage}${cause}`; const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message, error, error?.stack); console.error(message);
dialog.showMessageBox({ console.trace(error);
dialog.showMessageBox(win, {
type: 'info', type: 'info',
buttons: ['OK'], buttons: [t('plugins.downloader.backend.dialog.error.buttons.ok')],
title: 'Error in download!', title: t('plugins.downloader.backend.dialog.error.title'),
message: 'Argh! Apologies, download failed…', message: t('plugins.downloader.backend.dialog.error.message'),
detail: message, detail: message,
}); });
}; };
@ -88,43 +93,35 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
.join(';'); .join(';');
}; };
export default async (win_: BrowserWindow) => { let config: DownloaderPluginConfig;
win = win_;
injectCSS(win.webContents, style); export const onMainLoad = async ({
window: _win,
getConfig,
ipc,
}: BackendContext<DownloaderPluginConfig>) => {
win = _win;
config = await getConfig();
yt = await Innertube.create({ yt = await Innertube.create({
cache: new UniversalCache(false), cache: new UniversalCache(false),
cookie: await getCookieFromWindow(win), cookie: await getCookieFromWindow(win),
generate_session_locally: true, generate_session_locally: true,
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { fetch: getNetFetchAsFetch(),
const url =
typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
if (init?.body && !init.method) {
init.method = 'POST';
}
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
return net.fetch(request, init);
}) as typeof fetch,
}); });
ipcMain.on('download-song', (_, url: string) => downloadSong(url)); ipc.handle('download-song', (url: string) => downloadSong(url));
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => { ipc.on('video-src-changed', (data: GetPlayerResponse) => {
playingUrl = data.microformat.microformatDataRenderer.urlCanonical; playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
}); });
ipcMain.on('download-playlist-request', async (_event, url: string) => ipc.handle('download-playlist-request', async (url: string) =>
downloadPlaylist(url), downloadPlaylist(url),
); );
}; };
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
config = newConfig;
};
export async function downloadSong( export async function downloadSong(
url: string, url: string,
playlistFolder: string | undefined = undefined, playlistFolder: string | undefined = undefined,
@ -184,20 +181,25 @@ async function downloadSongUnsafe(
} }
}; };
sendFeedback('Downloading...', 2); sendFeedback(t('plugins.downloader.backend.feedback.downloading'), 2);
let id: string | null; let id: string | null;
if (isId) { if (isId) {
id = idOrUrl; id = idOrUrl;
} else { } else {
id = getVideoId(idOrUrl); id = getVideoId(idOrUrl);
if (typeof id !== 'string') throw new Error('Video not found'); if (typeof id !== 'string')
throw new Error(
t('plugins.downloader.backend.feedback.video-id-not-found'),
);
} }
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id); let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
if (!info) { if (!info) {
throw new Error('Video not found'); throw new Error(
t('plugins.downloader.backend.feedback.video-id-not-found'),
);
} }
const metadata = getMetadata(info); const metadata = getMetadata(info);
@ -208,7 +210,7 @@ async function downloadSongUnsafe(
metadata.trackId = trackId; metadata.trackId = trackId;
const dir = const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads'); playlistFolder || config.downloadFolder || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${ const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title metadata.title
}`; }`;
@ -238,11 +240,10 @@ async function downloadSongUnsafe(
); );
} }
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)'; const selectedPreset = config.selectedPreset ?? 'mp3 (256kbps)';
let presetSetting: Preset; let presetSetting: Preset;
if (selectedPreset === 'Custom') { if (selectedPreset === 'Custom') {
presetSetting = presetSetting = config.customPresetSetting ?? DefaultPresetList['Custom'];
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
} else if (selectedPreset === 'Source') { } else if (selectedPreset === 'Source') {
presetSetting = DefaultPresetList['Source']; presetSetting = DefaultPresetList['Source'];
} else { } else {
@ -275,7 +276,7 @@ async function downloadSongUnsafe(
} }
const filePath = join(dir, filename); const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) { if (config.skipExisting && existsSync(filePath)) {
sendFeedback(null, -1); sendFeedback(null, -1);
return; return;
} }
@ -283,7 +284,11 @@ async function downloadSongUnsafe(
const stream = await info.download(downloadOptions); const stream = await info.download(downloadOptions);
console.info( console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`, t('plugins.downloader.backend.feedback.download-info', {
artist: metadata.artist,
title: metadata.title,
videoId: metadata.videoId,
}),
); );
const iterableStream = Utils.streamToIterable(stream); const iterableStream = Utils.streamToIterable(stream);
@ -318,7 +323,11 @@ async function downloadSongUnsafe(
} }
sendFeedback(null, -1); sendFeedback(null, -1);
console.info(`Done: "${filePath}"`); console.info(
t('plugins.downloader.backend.feedback.done', {
filePath,
}),
);
} }
async function iterableStreamToTargetFile( async function iterableStreamToTargetFile(
@ -329,7 +338,7 @@ async function iterableStreamToTargetFile(
contentLength: number, contentLength: number,
sendFeedback: (str: string, value?: number) => void, sendFeedback: (str: string, value?: number) => void,
increasePlaylistProgress: (value: number) => void = () => {}, increasePlaylistProgress: (value: number) => void = () => {},
) { ): Promise<Uint8Array | null> {
const chunks = []; const chunks = [];
let downloaded = 0; let downloaded = 0;
for await (const chunk of stream) { for await (const chunk of stream) {
@ -337,13 +346,18 @@ async function iterableStreamToTargetFile(
chunks.push(chunk); chunks.push(chunk);
const ratio = downloaded / contentLength; const ratio = downloaded / contentLength;
const progress = Math.floor(ratio * 100); const progress = Math.floor(ratio * 100);
sendFeedback(`Download: ${progress}%`, ratio); sendFeedback(
t('plugins.downloader.backend.feedback.download-progress', {
percent: progress,
}),
ratio,
);
// 15% for download, 85% for conversion // 15% for download, 85% for conversion
// This is a very rough estimate, trying to make the progress bar look nice // This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15); increasePlaylistProgress(ratio * 0.15);
} }
sendFeedback('Loading', 2); // Indefinite progress bar after download sendFeedback(t('plugins.downloader.backend.feedback.loading'), 2); // Indefinite progress bar after download
const buffer = Buffer.concat(chunks); const buffer = Buffer.concat(chunks);
const safeVideoName = randomBytes(32).toString('hex'); const safeVideoName = randomBytes(32).toString('hex');
@ -354,13 +368,18 @@ async function iterableStreamToTargetFile(
await ffmpeg.load(); await ffmpeg.load();
} }
sendFeedback('Preparing file'); sendFeedback(t('plugins.downloader.backend.feedback.preparing-file'));
ffmpeg.FS('writeFile', safeVideoName, buffer); ffmpeg.FS('writeFile', safeVideoName, buffer);
sendFeedback('Converting'); sendFeedback(t('plugins.downloader.backend.feedback.converting'));
ffmpeg.setProgress(({ ratio }) => { ffmpeg.setProgress(({ ratio }) => {
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio); sendFeedback(
t('plugins.downloader.backend.feedback.conversion-progress', {
percent: Math.floor(ratio * 100),
}),
ratio,
);
increasePlaylistProgress(0.15 + (ratio * 0.85)); increasePlaylistProgress(0.15 + (ratio * 0.85));
}); });
@ -377,7 +396,7 @@ async function iterableStreamToTargetFile(
ffmpeg.FS('unlink', safeVideoName); ffmpeg.FS('unlink', safeVideoName);
} }
sendFeedback('Saving'); sendFeedback(t('plugins.downloader.backend.feedback.saving'));
try { try {
return ffmpeg.FS('readFile', safeVideoNameWithExtension); return ffmpeg.FS('readFile', safeVideoNameWithExtension);
@ -389,6 +408,7 @@ async function iterableStreamToTargetFile(
} finally { } finally {
releaseFFmpegMutex(); releaseFFmpegMutex();
} }
return null;
} }
const getCoverBuffer = cache(async (url: string) => { const getCoverBuffer = cache(async (url: string) => {
@ -402,7 +422,7 @@ async function writeID3(
sendFeedback: (str: string, value?: number) => void, sendFeedback: (str: string, value?: number) => void,
) { ) {
try { try {
sendFeedback('Writing ID3 tags...'); sendFeedback(t('plugins.downloader.backend.feedback.writing-id3'));
const tags: NodeID3.Tags = {}; const tags: NodeID3.Tags = {};
// Create the metadata tags // Create the metadata tags
@ -454,18 +474,23 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
} }
const playlistId = const playlistId =
getPlaylistID(givenUrl) || getPlaylistID(givenUrl) || getPlaylistID(new URL(playingUrl));
getPlaylistID(new URL(playingUrl));
if (!playlistId) { if (!playlistId) {
sendError(new Error('No playlist ID found')); sendError(
new Error(t('plugins.downloader.backend.feedback.playlist-id-not-found')),
);
return; return;
} }
const sendFeedback = (message?: unknown) => sendFeedback_(win, message); const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`); console.log(
sendFeedback('Getting playlist info…'); t('plugins.downloader.backend.feedback.trying-to-get-playlist-id', {
playlistId,
}),
);
sendFeedback(t('plugins.downloader.backend.feedback.getting-playlist-info'));
let playlist: Playlist; let playlist: Playlist;
const items: YTNodes.MusicResponsiveListItem[] = []; const items: YTNodes.MusicResponsiveListItem[] = [];
try { try {
@ -476,16 +501,18 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
} catch (error: unknown) { } catch (error: unknown) {
sendError( sendError(
Error( Error(
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String( t('plugins.downloader.backend.feedback.playlist-is-mix-or-private', {
error, error: String(error),
)}`, }),
), ),
); );
return; return;
} }
if (!playlist || !playlist.items || playlist.items.length === 0) { if (!playlist || !playlist.items || playlist.items.length === 0) {
sendError(new Error('Playlist is empty')); sendError(
new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')),
);
} }
const normalPlaylistTitle = playlist.header?.title?.text; const normalPlaylistTitle = playlist.header?.title?.text;
@ -506,7 +533,9 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
} }
if (items.length === 1) { if (items.length === 1) {
sendFeedback('Playlist has only one item, downloading it directly'); sendFeedback(
t('plugins.downloader.backend.feedback.playlist-has-only-one-song'),
);
await downloadSongFromId(items.at(0)!.id!); await downloadSongFromId(items.at(0)!.id!);
return; return;
} }
@ -516,28 +545,50 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
safePlaylistTitle = safePlaylistTitle.normalize('NFC'); safePlaylistTitle = safePlaylistTitle.normalize('NFC');
} }
const folder = getFolder(config.get('downloadFolder') ?? ''); const folder = getFolder(config.downloadFolder ?? '');
const playlistFolder = join(folder, safePlaylistTitle); const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) { if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) { if (!config.skipExisting) {
sendError(new Error(`The folder ${playlistFolder} already exists`)); sendError(
new Error(
t('plugins.downloader.backend.feedback.folder-already-exists', {
playlistFolder,
}),
),
);
return; return;
} }
} else { } else {
mkdirSync(playlistFolder, { recursive: true }); mkdirSync(playlistFolder, { recursive: true });
} }
dialog.showMessageBox({ dialog.showMessageBox(win, {
type: 'info', type: 'info',
buttons: ['OK'], buttons: [
title: 'Started Download', t('plugins.downloader.backend.dialog.start-download-playlist.buttons.ok'),
message: `Downloading Playlist "${playlistTitle}"`, ],
detail: `(${items.length} songs)`, title: t('plugins.downloader.backend.dialog.start-download-playlist.title'),
message: t(
'plugins.downloader.backend.dialog.start-download-playlist.message',
{
playlistTitle,
},
),
detail: t(
'plugins.downloader.backend.dialog.start-download-playlist.detail',
{
playlistSize: items.length,
},
),
}); });
if (is.dev()) { if (is.dev()) {
console.log( console.log(
`Downloading playlist "${playlistTitle}" - ${items.length} songs (${playlistId})`, t('plugins.downloader.backend.feedback.downloading-playlist', {
playlistTitle,
playlistSize: items.length,
playlistId,
}),
); );
} }
@ -557,7 +608,12 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
try { try {
for (const song of items) { for (const song of items) {
sendFeedback(`Downloading ${counter}/${items.length}...`); sendFeedback(
t('plugins.downloader.backend.feedback.downloading-counter', {
current: counter,
total: items.length,
}),
);
const trackId = isAlbum ? counter : undefined; const trackId = isAlbum ? counter : undefined;
await downloadSongFromId( await downloadSongFromId(
song.id!, song.id!,
@ -567,9 +623,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
).catch((error) => ).catch((error) =>
sendError( sendError(
new Error( new Error(
`Error downloading "${ t('plugins.downloader.backend.feedback.error-while-downloading', {
song.author!.name author: song.author!.name,
} - ${song.title!}":\n ${error}`, title: song.title!,
error: String(error),
}),
), ),
), ),
); );
@ -636,6 +694,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
client_type: ClientType.TV_EMBEDDED, client_type: ClientType.TV_EMBEDDED,
generate_session_locally: true, generate_session_locally: true,
retrieve_player: true, retrieve_player: true,
fetch: getNetFetchAsFetch(),
}); });
// GetInfo 404s with the bypass, so we use getBasicInfo instead // GetInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data // that's fine as we only need the streaming data

View File

@ -1,8 +1,8 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads'); export const getFolder = (customFolder: string) =>
export const defaultMenuDownloadLabel = 'Download playlist'; customFolder || app.getPath('downloads');
export const sendFeedback = (win: BrowserWindow, message?: unknown) => { export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
win.webContents.send('downloader-feedback', message); win.webContents.send('downloader-feedback', message);

View File

@ -1,46 +1,57 @@
import { dialog } from 'electron'; import { dialog } from 'electron';
import { downloadPlaylist } from './back'; import { downloadPlaylist } from './main';
import { defaultMenuDownloadLabel, getFolder } from './utils'; import { getFolder } from './main/utils';
import { DefaultPresetList } from './types'; import { DefaultPresetList } from './types';
import config from './config';
import { MenuTemplate } from '../../menu'; import { t } from '@/i18n';
export default (): MenuTemplate => [ import type { MenuContext } from '@/types/contexts';
{ import type { MenuTemplate } from '@/menu';
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(), import type { DownloaderPluginConfig } from './index';
},
{ export const onMenu = async ({
label: 'Choose download folder', getConfig,
click() { setConfig,
const result = dialog.showOpenDialogSync({ }: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
properties: ['openDirectory', 'createDirectory'], const config = await getConfig();
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
}); return [
if (result) { {
config.set('downloadFolder', result[0]); label: t('plugins.downloader.menu.download-playlist'),
} // Else = user pressed cancel click: () => downloadPlaylist(),
}, },
}, {
{ label: t('plugins.downloader.menu.choose-download-folder'),
label: 'Presets',
submenu: Object.keys(DefaultPresetList).map((preset) => ({
label: preset,
type: 'radio',
checked: config.get('selectedPreset') === preset,
click() { click() {
config.set('selectedPreset', preset); const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.downloadFolder ?? ''),
});
if (result) {
setConfig({ downloadFolder: result[0] });
} // Else = user pressed cancel
}, },
})),
},
{
label: 'Skip existing files',
type: 'checkbox',
checked: config.get('skipExisting'),
click(item) {
config.set('skipExisting', item.checked);
}, },
}, {
]; label: t('plugins.downloader.menu.presets'),
submenu: Object.keys(DefaultPresetList).map((preset) => ({
label: preset,
type: 'radio',
checked: config.selectedPreset === preset,
click() {
setConfig({ selectedPreset: preset });
},
})),
},
{
label: t('plugins.downloader.menu.skip-existing'),
type: 'checkbox',
checked: config.skipExisting,
click(item) {
setConfig({ skipExisting: item.checked });
},
},
];
};

View File

@ -0,0 +1,95 @@
import downloadHTML from './templates/download.html?raw';
import defaultConfig from '@/config/defaults';
import { getSongMenu } from '@/providers/dom-elements';
import { getSongInfo } from '@/providers/song-info-front';
import { LoggerPrefix } from '@/utils';
import { t } from '@/i18n';
import { ElementFromHtml } from '../utils/renderer';
import type { RendererContext } from '@/types/contexts';
import type { DownloaderPluginConfig } from './index';
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
if (menu.contains(downloadButton)) {
return;
}
const menuUrl = document.querySelector<HTMLAnchorElement>(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => (doneFirstLoad ||= true), 500);
});
export const onRendererLoad = ({
ipc,
}: RendererContext<DownloaderPluginConfig>) => {
window.download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector(
'ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint',
)
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipc.invoke('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
ipc.invoke('download-song', videoUrl);
};
ipc.on('downloader-feedback', (feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn(
LoggerPrefix,
t('plugins.downloader.renderer.can-not-update-progress'),
);
}
});
};
export const onPlayerApiReady = () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
};

View File

@ -1,4 +1,4 @@
.menu-item { .ytmd-menu-item {
display: var(--ytmusic-menu-item_-_display); display: var(--ytmusic-menu-item_-_display);
height: var(--ytmusic-menu-item_-_height); height: var(--ytmusic-menu-item_-_height);
align-items: var(--ytmusic-menu-item_-_align-items); align-items: var(--ytmusic-menu-item_-_align-items);
@ -6,11 +6,11 @@
cursor: pointer; cursor: pointer;
} }
.menu-item > .yt-simple-endpoint:hover { .ytmd-menu-item > .yt-simple-endpoint:hover {
background-color: var(--ytmusic-menu-item-hover-background-color); background-color: var(--ytmusic-menu-item-hover-background-color);
} }
.menu-icon { .ytmd-menu-item {
flex: var(--ytmusic-menu-item-icon_-_flex); flex: var(--ytmusic-menu-item-icon_-_flex);
margin: var(--ytmusic-menu-item-icon_-_margin); margin: var(--ytmusic-menu-item-icon_-_margin);
fill: var(--ytmusic-menu-item-icon_-_fill); fill: var(--ytmusic-menu-item-icon_-_fill);

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