mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
Compare commits
635 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 022f8ff65c | |||
| 5e63cc2e89 | |||
| 880ed99846 | |||
| 222e78c85b | |||
| 050d55c736 | |||
| 13ef8560ff | |||
| 78d990c079 | |||
| 4d3e2c09da | |||
| aa899d247a | |||
| ee0c512529 | |||
| 5f9b522307 | |||
| c207e29980 | |||
| df4d2d6b72 | |||
| c3dd20cabd | |||
| 7a6db95d1a | |||
| bc6825d63b | |||
| 5e79e9e0f2 | |||
| 5e303c2ba8 | |||
| 0bd9c16356 | |||
| f0f5d9da2f | |||
| f46c431f4c | |||
| 62410e9ee2 | |||
| 46f76f1408 | |||
| 5e071e16d8 | |||
| c0238588bd | |||
| 30002d660a | |||
| 48eeb6bca3 | |||
| e67699fed5 | |||
| 8aeae45965 | |||
| ce7491941b | |||
| 1dce03c4f2 | |||
| 62eae6d5d0 | |||
| 15b2b26b84 | |||
| 9664c17c47 | |||
| 8067dad2fa | |||
| 4dcaa510d9 | |||
| b6e918089d | |||
| 1c9e6b1bb8 | |||
| ebd304c252 | |||
| 36083c4173 | |||
| a084b060d8 | |||
| 432c79b606 | |||
| 0f1f0ee933 | |||
| 9b1a4b8d88 | |||
| 1a7a665915 | |||
| 623ecf7fb8 | |||
| 0dc9c6a1a9 | |||
| 72c5eaa5ff | |||
| 0f47b94b7d | |||
| 9abe15f1ad | |||
| 96afda92c8 | |||
| 5c6fd4a739 | |||
| 23b87a876d | |||
| 737fd05369 | |||
| c5bcd89f16 | |||
| 377e1be0b2 | |||
| a92049c0c9 | |||
| 27a2955bba | |||
| cc940e2020 | |||
| 203c80767d | |||
| f564039438 | |||
| d6566fb870 | |||
| 55ab17e789 | |||
| e0eeb720cd | |||
| 53ac7ff257 | |||
| a4e2f10afa | |||
| c9c9d766e6 | |||
| b82d161180 | |||
| 7cadacd8cf | |||
| fc1a7cda62 | |||
| da3bc5aeb7 | |||
| ee98344064 | |||
| da10eabf99 | |||
| ce3c63c386 | |||
| f8c3cff5a9 | |||
| 08f369f8bc | |||
| 1517230ede | |||
| 45aeadef86 | |||
| 5ec7c346ca | |||
| 4b68de1606 | |||
| 7710bcdacc | |||
| 4c19bcc983 | |||
| 2f50b1423a | |||
| 15768c9691 | |||
| 442eb75b8d | |||
| 8facd27125 | |||
| a302cb6ebf | |||
| 32422c7ba9 | |||
| ca131cb156 | |||
| 5f8262ede1 | |||
| 88db9d3693 | |||
| 0fa6f344f9 | |||
| d1642b3dfb | |||
| 7599fce5f5 | |||
| 9bd0dc9a7b | |||
| dad7cafebc | |||
| 4febb28201 | |||
| ced943bad3 | |||
| 202b8717a2 | |||
| eba7026b89 | |||
| 6b2adca1f4 | |||
| 0c9d3cc057 | |||
| 45ec11aaed | |||
| 4d47fa3fac | |||
| 6322d5bb09 | |||
| 2e8a579049 | |||
| 9db7e99d7f | |||
| ef73989995 | |||
| 61024084e9 | |||
| 1c71b2020f | |||
| 3b2b545388 | |||
| fafc7f94fd | |||
| 5fa88b5ce9 | |||
| a8fe4167db | |||
| 0e6b0ddc26 | |||
| af88db3865 | |||
| 0a1bef556c | |||
| 7030181dc1 | |||
| 98cb2f61ab | |||
| a601d0b3d2 | |||
| 8d18923065 | |||
| 4b6a7e0bd7 | |||
| 44f87853d6 | |||
| 4ff47bee03 | |||
| 7a2c28df21 | |||
| f7241d1e4a | |||
| 2b5daf3a75 | |||
| d7184cf75d | |||
| 1313d18d44 | |||
| 605e9481d9 | |||
| cf4c89f825 | |||
| f4f1d4d373 | |||
| 6557b40924 | |||
| e01a29c5d1 | |||
| d9a7e16d63 | |||
| d170199d85 | |||
| e650aae491 | |||
| 49058dbeab | |||
| 31d2d91566 | |||
| abf2a52b46 | |||
| d0ca10e1a1 | |||
| fe99a04174 | |||
| 916900ae6a | |||
| 826c9ba48e | |||
| 7783cc5f30 | |||
| 035d7f19ea | |||
| 294cf15d58 | |||
| 11fa0dcbc6 | |||
| 85b53db439 | |||
| 200df100f4 | |||
| ba202a8572 | |||
| daf05239a1 | |||
| b73b5735ec | |||
| 292248dd35 | |||
| 2e240541c8 | |||
| ab62ae3682 | |||
| 5c3f5d05d3 | |||
| c81af537b6 | |||
| 7295c73371 | |||
| 5a65e08a94 | |||
| 45e5bc7df5 | |||
| a81cb9515c | |||
| 88e5cc2728 | |||
| 1685648328 | |||
| 9334adacf6 | |||
| efbe557dce | |||
| 52b2625486 | |||
| aa0424db08 | |||
| 157619aa4f | |||
| ce637968b2 | |||
| b2a3ed7428 | |||
| ca856e4d88 | |||
| 2ef2536766 | |||
| 1b22633388 | |||
| db306ad4e0 | |||
| 34a44cb9c6 | |||
| 8c499a6e20 | |||
| 3b816a6fd9 | |||
| 35aba8e25a | |||
| 0eb6723f27 | |||
| 2250ec9372 | |||
| ef02b53284 | |||
| 57df7cf3f9 | |||
| f0cd540726 | |||
| 7e919395eb | |||
| facc7252c2 | |||
| 09b8943f69 | |||
| aa84ffc7e1 | |||
| 10162c948b | |||
| e8c1cbdd94 | |||
| 5459f623ec | |||
| 2a2cba3539 | |||
| 1ce84607e6 | |||
| 075c54d39f | |||
| aabb826cb8 | |||
| 3791b1ae1c | |||
| d4b87d098b | |||
| 57e9c13d13 | |||
| e48e570e69 | |||
| b7d4d5b022 | |||
| dc8b0173e9 | |||
| 840729126e | |||
| ed90b97a92 | |||
| bbe4f07651 | |||
| 66df83a86d | |||
| dfedd8f091 | |||
| 58ec6eebfb | |||
| 3ba4130bc6 | |||
| 7dbbf38e78 | |||
| 51e04162a7 | |||
| d3325e2490 | |||
| d2708ee32f | |||
| 781a09c717 | |||
| 11605293dd | |||
| 3f0b946190 | |||
| 74972f053a | |||
| d85190ace1 | |||
| 5755b4ac7f | |||
| 3c24f2edcd | |||
| 6828520853 | |||
| 2ba7dddb95 | |||
| 57a8922d04 | |||
| 640098860a | |||
| 53384d9f3b | |||
| 89d8d98a35 | |||
| 7b78ba6761 | |||
| 3926a9a0c0 | |||
| 0708cd5a38 | |||
| e20e9ca771 | |||
| be1038bafd | |||
| ebc087963b | |||
| 020bdc0811 | |||
| a0d1ad6a47 | |||
| 62e5679791 | |||
| 35568bd299 | |||
| 1e40b377af | |||
| 1073de1b45 | |||
| 1d5788acaf | |||
| 8fb446588d | |||
| 6cec34b2ac | |||
| 763d3e8f74 | |||
| 7da0a913f1 | |||
| ca04c4561b | |||
| 7d8fbf49a8 | |||
| 75e15b948d | |||
| 125b69fd75 | |||
| a68d6b64dd | |||
| a60d4264dc | |||
| 9e2c6b1afa | |||
| 14965a93e9 | |||
| f62664b6a5 | |||
| 60cb7f32f1 | |||
| 008b3ad710 | |||
| 8cae64f496 | |||
| 5cdc1bc762 | |||
| 15c455105b | |||
| 14407a98c9 | |||
| 0d004d5caf | |||
| 4b75a2405c | |||
| 1f7e28b6fb | |||
| c41b2ce861 | |||
| 7f02afc5a6 | |||
| 496b3ffc1b | |||
| e9a395f67a | |||
| 0660f0b7ce | |||
| 3ac09b9dc1 | |||
| fe4904a4af | |||
| d8c8bd17ec | |||
| e9d4d5ba14 | |||
| 5b2e69588f | |||
| c1591402a0 | |||
| e2e9c03895 | |||
| deac4ef56b | |||
| 7c39e658ce | |||
| 6b026f57bc | |||
| dc07cbda6f | |||
| 1cf43fcd42 | |||
| e2cf550bed | |||
| 2917da1138 | |||
| b74eeb5688 | |||
| 0b084a6441 | |||
| 865efa1b12 | |||
| 6a248e5336 | |||
| eb9c256a5d | |||
| 4bd54dcb2d | |||
| 17b035d317 | |||
| 28bcd1fefc | |||
| 59bb1d9124 | |||
| d9255c1cec | |||
| 4ab4bb4cb3 | |||
| a6c8b887e3 | |||
| 1db0abf32d | |||
| ff899b8720 | |||
| 18004c4441 | |||
| ce1cde72bd | |||
| 453f4d92c9 | |||
| 37740e78b4 | |||
| 8ace123179 | |||
| bcdb9de41a | |||
| 9fdb6eb7e5 | |||
| 88cd1d2390 | |||
| 943bcd322d | |||
| 7774128d7e | |||
| b9b9e2ba00 | |||
| 0ce4f20ec5 | |||
| 51b87312c4 | |||
| 9ffd7af8a7 | |||
| 4a453a4f3d | |||
| dc8a472cdb | |||
| d2eabaa4bb | |||
| 39c8ca66d1 | |||
| 806098a5ef | |||
| 5f6cfd9558 | |||
| b4b7ad824b | |||
| 7b5d602f63 | |||
| 7eeeb89457 | |||
| 6e8447b5d1 | |||
| a6445bacf0 | |||
| bd9b4f1b1a | |||
| 9a816b3f07 | |||
| 4dcac23688 | |||
| 97ef6ff997 | |||
| 244a656671 | |||
| f8a2829adb | |||
| 24daadbef8 | |||
| bf2ac88847 | |||
| e42423b100 | |||
| de0c02efaf | |||
| b8290417f8 | |||
| b92205a228 | |||
| 5f642007ba | |||
| ee40d278d4 | |||
| 02d2e8ea92 | |||
| 70715e5e8a | |||
| 5ae4f564b7 | |||
| 123eabd77a | |||
| fd3438a20d | |||
| c8554a12f6 | |||
| 4a687ade9c | |||
| f77aa372cc | |||
| 0b9eef94c4 | |||
| 71b2f69f98 | |||
| 4b61c5307e | |||
| a617b91263 | |||
| fc79bdd0f3 | |||
| e5e1e547d5 | |||
| edac9b0c20 | |||
| dfaf3cf95a | |||
| bae90ce8f3 | |||
| 188e56ce30 | |||
| 19b48b123f | |||
| 549961f297 | |||
| ba7bc68ac3 | |||
| bbfe272d41 | |||
| 8a3e0a31ca | |||
| 8fbda97885 | |||
| 1856deb0f5 | |||
| fec7c5c130 | |||
| 936b4b28bb | |||
| f3092d0778 | |||
| fc1adfae6c | |||
| e279aaed64 | |||
| 4d346a9471 | |||
| cfc504da34 | |||
| 0919a4b9b7 | |||
| f46ad2ea0e | |||
| 252719bc71 | |||
| 45f49361ea | |||
| c4a74c6c7e | |||
| 05f197948d | |||
| 5a1d230538 | |||
| a7ad260a00 | |||
| ef068cccd9 | |||
| 166067920d | |||
| 8227853cf9 | |||
| 324a539b89 | |||
| ce7557353c | |||
| 7b7923fe9b | |||
| 105d5c78e7 | |||
| b25183a8f5 | |||
| adde33d1f5 | |||
| ad325ccb10 | |||
| 2e7ea6969c | |||
| 7401cf69ad | |||
| 7f71c36dc0 | |||
| a3104fda4b | |||
| 44c42310f1 | |||
| a22a8ac5c9 | |||
| aa5c3bac4e | |||
| 30b3beee18 | |||
| b059e43fb1 | |||
| 3b04d0ba19 | |||
| 959f99beae | |||
| ed402933d3 | |||
| ef8bb95884 | |||
| 1b79d2e429 | |||
| ec786748be | |||
| 06f1c7effe | |||
| d78da237fc | |||
| 4c0cce89ee | |||
| 888ced8fd1 | |||
| e1690720b3 | |||
| bbff0a6bc2 | |||
| 5db759150c | |||
| ae239f6700 | |||
| 1d26d10e57 | |||
| da70a4ce7e | |||
| 75ae9f4fad | |||
| 8f7933c111 | |||
| 29a0dedcce | |||
| 4d62993177 | |||
| 8714f33fa2 | |||
| 5dacd50ff6 | |||
| 8d06dcc7b6 | |||
| b8f6dd2584 | |||
| 0650205b86 | |||
| 3e8a0ec49a | |||
| 04d7b32d3f | |||
| eaaf170cc8 | |||
| 09450fb8c7 | |||
| ac0b78eefb | |||
| 90103d9853 | |||
| bf27c73f1d | |||
| 845c9365be | |||
| 91cf5f5c25 | |||
| 783a892e26 | |||
| 41d8f86962 | |||
| 252349579e | |||
| 99b1cfbde4 | |||
| 3f70d912d7 | |||
| bf33c4e7b4 | |||
| 3152842a30 | |||
| d84416b27c | |||
| cc38978bd3 | |||
| 7a76079ff4 | |||
| 2fe28cf126 | |||
| 3ffbfbe0e3 | |||
| 4fad456619 | |||
| 7591f13505 | |||
| 11d06c50a5 | |||
| e0a3489640 | |||
| e55a1d3076 | |||
| 563d431c00 | |||
| 3a1b77ebd8 | |||
| 3f8030a9c5 | |||
| e12e67af0e | |||
| 3ab4cd5d05 | |||
| 738adbed98 | |||
| 365a078600 | |||
| 04fc43e18b | |||
| 54273baec7 | |||
| 51e62ef47b | |||
| a330ebcda7 | |||
| a023fff2d0 | |||
| abb25ea6fb | |||
| ef49bcdb5f | |||
| b4f1b112d6 | |||
| f24ec0ae9d | |||
| ebb51fe37b | |||
| e8ee18f903 | |||
| a593de705c | |||
| 03dd024704 | |||
| 528c3535dd | |||
| 0e0f80a2d0 | |||
| 6b67fb136a | |||
| 9fe1c14869 | |||
| 8a96dddf54 | |||
| 230422c98b | |||
| d16ffc531f | |||
| f614199ea5 | |||
| 55a1c2e9e3 | |||
| bee1f77812 | |||
| fdf982ada5 | |||
| ff02fc7855 | |||
| 01ed289400 | |||
| aedb2db655 | |||
| 10a54b9de0 | |||
| ccd029c040 | |||
| 3a431841b7 | |||
| deceae8354 | |||
| c8628670cf | |||
| ffe53d5596 | |||
| a4f4ecb569 | |||
| 2097f42efb | |||
| 9c59f56aac | |||
| dfcc4107b7 | |||
| ef71abfff1 | |||
| bc916f3a6e | |||
| c7ff0dcbf6 | |||
| 7242f9bfd0 | |||
| bb2e865880 | |||
| 6ab3cf9ac9 | |||
| b77f5c9ecc | |||
| b470dbd6b9 | |||
| 1f96b6b44d | |||
| de0b228ae8 | |||
| f35d192650 | |||
| 794d00ce9e | |||
| 739e7a448b | |||
| 7fa8a454b6 | |||
| 5cd1d9abe8 | |||
| e0e17cac99 | |||
| 840039330f | |||
| 734409dc3f | |||
| 34564c8c55 | |||
| afe6accab8 | |||
| b6e7e75ae8 | |||
| 06dc0e80f0 | |||
| 47cccbce7c | |||
| 269352af97 | |||
| fa62f79dce | |||
| 9f88b37f41 | |||
| 55ae9eac1e | |||
| 05564d4a58 | |||
| 59426c56db | |||
| 18cd4c0c9a | |||
| a0e2a33e28 | |||
| 7bdb46e161 | |||
| f560b62de0 | |||
| adc1f6822b | |||
| 2da29fcfa7 | |||
| c5d0314db6 | |||
| 8c052faedd | |||
| 37067ff950 | |||
| 6366dc026e | |||
| 6e52178074 | |||
| 47f38cc690 | |||
| fdd6d9929f | |||
| 1707261f49 | |||
| 6712fced6d | |||
| 6dabfaa9ba | |||
| a41db79c35 | |||
| 87786d9aef | |||
| 22f5866050 | |||
| 04894fbcf5 | |||
| c17c624ba4 | |||
| bfe7249df8 | |||
| 13c570efe9 | |||
| b299846f0f | |||
| 59e9289d27 | |||
| 8dc29caa1b | |||
| 7fedf88654 | |||
| 5da0202425 | |||
| 6288d0b171 | |||
| 4248d20e8e | |||
| 0b413492ad | |||
| dc73561c8a | |||
| 949a2f6428 | |||
| bceaa05197 | |||
| 776cdac30d | |||
| 4333891cca | |||
| 8a89bbccf7 | |||
| fa4c69d228 | |||
| c25def8901 | |||
| 284a59b721 | |||
| 5fcba8619a | |||
| f3cd759276 | |||
| 9d3981e361 | |||
| 787326948b | |||
| 779251933c | |||
| 1efe835c69 | |||
| 5702978227 | |||
| fa3d742838 | |||
| c460cc2296 | |||
| 4e4af5e830 | |||
| 9a4e98063b | |||
| 8bfe04bb50 | |||
| 6774d54f5e | |||
| 9705f8489d | |||
| a7229cbe14 | |||
| 7577aba45e | |||
| d78fbe476e | |||
| bfe4b2bba7 | |||
| 7625a3aa52 | |||
| 30c8dcf730 | |||
| 00a3e8d35e | |||
| 4d01cdfa6c | |||
| f924b6c8e3 | |||
| 926d98174c | |||
| 41b3972f54 | |||
| 467f29e363 | |||
| 9cc13c3757 | |||
| f8ccb86156 | |||
| b316aa2301 | |||
| 5c49b28664 | |||
| dedf96afd3 | |||
| 3bb5bc2ca1 | |||
| c79fdd9887 | |||
| d7b821727d | |||
| 21c45faf20 | |||
| 92cab89d17 | |||
| fa160b2e90 | |||
| 308ac38e6b | |||
| a62cafb601 | |||
| bf9e3b5f48 | |||
| 3c6b3aeff0 | |||
| 37181a7b5e | |||
| 0b363d6487 | |||
| e9398adac3 | |||
| 6901713036 | |||
| 1d5b2997bd | |||
| 572a023aaa | |||
| 9187f1e240 | |||
| df13d7d0f3 | |||
| 85228fd7d2 | |||
| 17ba071057 | |||
| d7df4d7d10 | |||
| 7aa970cebc | |||
| f08f003cf4 | |||
| 9f99eded9e | |||
| c512f13009 | |||
| b475f780ff | |||
| 2294102006 | |||
| d69a07d025 | |||
| 4f4995c20c | |||
| b6894dca29 | |||
| 73f14e581d | |||
| 2f2e64af4a | |||
| 5710307ddc | |||
| 52ba2dc9ff | |||
| 926b9fb5e6 | |||
| a6c9b3381a | |||
| 5dc13a4698 | |||
| a69085c591 | |||
| a22f7fed21 | |||
| 8b7045fb1b | |||
| efd1b92514 | |||
| 969f6d7bba | |||
| 4f7c92d6a0 | |||
| 24d4a50574 | |||
| 7693a3ba4a | |||
| 7ca4dc5c85 | |||
| 21ff09b605 | |||
| fbf4b3b8b5 | |||
| 5812eb0147 |
@ -1,3 +1 @@
|
|||||||
.eslintrc.js
|
.eslintrc.js
|
||||||
rollup.main.config.ts
|
|
||||||
rollup.preload.config.ts
|
|
||||||
|
|||||||
13
.eslintrc.js
13
.eslintrc.js
@ -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: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
27
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
27
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -12,16 +12,23 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have searched the [issue tracker](https://github.com/th-ch/youtube-music/issues) for a bug report that matches the one I want to file, without success.
|
- label: I have searched the [issue tracker](https://github.com/th-ch/youtube-music/issues) for a bug report that matches the one I want to file, without success.
|
||||||
required: true
|
required: true
|
||||||
|
- label: I understand that **th-ch/youtube-music has NO affiliation with Google or YouTube**
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: YouTube Music (Application) Version
|
label: YouTube Music (Application) Version
|
||||||
description: |
|
description: |
|
||||||
What version of YouTube Music Application are you using?
|
What version of the YouTube Music Application are you using?
|
||||||
|
|
||||||
Note: Please check if this issue is reproducible with the latest stable release.
|
Note: Please check if this issue is reproducible with the latest stable release.
|
||||||
placeholder: 2.0.0
|
placeholder: 2.0.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Are you using the portable version of the YouTube Music Application?
|
||||||
|
options:
|
||||||
|
- label: I use the portable version of the YouTube Music Application.
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: What operating system are you using?
|
label: What operating system are you using?
|
||||||
@ -36,7 +43,7 @@ body:
|
|||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Operating System Version
|
label: Operating System Version
|
||||||
description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a.
|
description: What operating system version are you using? On Windows, click the Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a.
|
||||||
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
|
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@ -55,10 +62,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
|
||||||
@ -67,6 +81,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
|
||||||
|
|||||||
113
.github/workflows/build.yml
vendored
113
.github/workflows/build.yml
vendored
@ -20,59 +20,63 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
if: startsWith(matrix.os, 'macOS') != true
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Setup NodeJS for macOS
|
||||||
|
if: startsWith(matrix.os, 'macOS')
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Only vite build without release if it is a fork, or it is a pull-request
|
||||||
|
- name: Vite Build
|
||||||
|
if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Build and release if it's the main repository and is not pull-request
|
||||||
|
- name: Build and release on Mac
|
||||||
|
if: startsWith(matrix.os, 'macOS') && (github.repository == 'th-ch/youtube-music' && github.event_name != 'pull_request')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
pnpm release:mac
|
||||||
|
|
||||||
|
- name: Build and release on Linux
|
||||||
|
if: startsWith(matrix.os, 'ubuntu') && (github.repository == 'th-ch/youtube-music' && github.event_name != 'pull_request')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
pnpm release:linux
|
||||||
|
|
||||||
|
- name: Build and release on Windows
|
||||||
|
if: startsWith(matrix.os, 'windows') && (github.repository == 'th-ch/youtube-music' && github.event_name != 'pull_request')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
pnpm release:win
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: GabrielBB/xvfb-action@v1
|
uses: coactions/setup-xvfb@v1
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
with:
|
with:
|
||||||
run: npm run test
|
run: pnpm test:debug
|
||||||
|
|
||||||
# Build and release if it's the main repository
|
|
||||||
- name: Build and release on Mac
|
|
||||||
if: startsWith(matrix.os, 'macOS') && github.repository == 'th-ch/youtube-music'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
npm run release:mac
|
|
||||||
|
|
||||||
- name: Build and release on Linux
|
|
||||||
if: startsWith(matrix.os, 'ubuntu') && github.repository == 'th-ch/youtube-music'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
npm run release:linux
|
|
||||||
|
|
||||||
- name: Build and release on Windows
|
|
||||||
if: startsWith(matrix.os, 'windows') && github.repository == 'th-ch/youtube-music'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
npm run release:win
|
|
||||||
|
|
||||||
# Only build without release if it is a fork
|
|
||||||
- name: Build on Mac
|
|
||||||
if: startsWith(matrix.os, 'macOS') && github.repository != 'th-ch/youtube-music'
|
|
||||||
run: |
|
|
||||||
npm run build:mac
|
|
||||||
|
|
||||||
- name: Build on Linux
|
|
||||||
if: startsWith(matrix.os, 'ubuntu') && github.repository != 'th-ch/youtube-music'
|
|
||||||
run: |
|
|
||||||
npm run build:linux
|
|
||||||
|
|
||||||
- name: Build on Windows
|
|
||||||
if: startsWith(matrix.os, 'windows') && github.repository != 'th-ch/youtube-music'
|
|
||||||
run: |
|
|
||||||
npm run build:win
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -84,14 +88,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
if: startsWith(matrix.os, 'macOS') != true
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Setup NodeJS for macOS
|
||||||
|
if: startsWith(matrix.os, 'macOS')
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
run: |
|
run: |
|
||||||
@ -117,7 +134,7 @@ jobs:
|
|||||||
if: ${{ env.VERSION_HASH == '' }}
|
if: ${{ env.VERSION_HASH == '' }}
|
||||||
uses: irongut/EditRelease@v1.2.0
|
uses: irongut/EditRelease@v1.2.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GH_TOKEN }}
|
||||||
id: ${{ steps.get_draft_release.outputs.id }}
|
id: ${{ steps.get_draft_release.outputs.id }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
@ -129,10 +146,12 @@ 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: |
|
||||||
npm run changelog
|
pnpm changelog
|
||||||
|
|
||||||
- name: Commit changelog
|
- name: Commit changelog
|
||||||
if: ${{ env.VERSION_HASH == '' }}
|
if: ${{ env.VERSION_HASH == '' }}
|
||||||
|
|||||||
20
.github/workflows/winget-cla.yml
vendored
Normal file
20
.github/workflows/winget-cla.yml
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
name: Submit CLA to Winget PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pr_url:
|
||||||
|
description: "Specific PR URL"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
name: Comment to PR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Submit CLA to Windows Package Manager Community Repository Pull Request
|
||||||
|
run: gh pr comment $PR_URL --body "@microsoft-github-policy-service agree"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||||
|
PR_URL: ${{ inputs.pr_url }}
|
||||||
8
.github/workflows/winget-submission.yml
vendored
8
.github/workflows/winget-submission.yml
vendored
@ -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-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
1
.gitignore
vendored
@ -12,3 +12,4 @@ electron-builder.yml
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
.vite-inspect
|
||||||
|
|||||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
@ -6,9 +6,9 @@
|
|||||||
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
||||||
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
||||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
[](https://snyk.io/test/github/th-ch/youtube-music)
|
|
||||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
||||||
|
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -16,16 +16,27 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/th-ch/youtube-music/releases/latest">
|
<a href="https://github.com/th-ch/youtube-music/releases/latest">
|
||||||
<img src="web/youtube-music.svg" width="400" height="100">
|
<img src="web/youtube-music.svg" width="400" height="100" alt="YouTube Music SVG">
|
||||||
</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
|
||||||
@ -38,7 +49,13 @@ this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Insta
|
|||||||
|
|
||||||
### MacOS
|
### MacOS
|
||||||
|
|
||||||
If you get an error "is damaged and can’t be opened." when launching the app, run the following in the Terminal:
|
You can install the app using Homebrew (see the [cask definition](https://github.com/th-ch/homebrew-youtube-music)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install th-ch/youtube-music/youtube-music
|
||||||
|
```
|
||||||
|
|
||||||
|
If you install the app manually and get an error "is damaged and can’t be opened." when launching the app, run the following in the Terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xattr -cr /Applications/YouTube\ Music.app
|
xattr -cr /Applications/YouTube\ Music.app
|
||||||
@ -75,10 +92,22 @@ 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
|
||||||
|
|
||||||
|
- **Album Color Theme**: Applies a dynamic theme and visual effects based on the album color palette
|
||||||
|
|
||||||
|
- **Ambient Mode**: Applies a lighting effect by casting gentle colors from the video, into your screen’s background.
|
||||||
|
|
||||||
- **Audio Compressor**: Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the
|
- **Audio Compressor**: Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the
|
||||||
volume of the softest parts)
|
volume of the softest parts)
|
||||||
|
|
||||||
@ -104,14 +133,15 @@ winget install th-ch.YouTubeMusic
|
|||||||
slider [exponential](https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/) so it's easier to
|
slider [exponential](https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/) so it's easier to
|
||||||
select lower volumes.
|
select lower volumes.
|
||||||
|
|
||||||
- **In-App Menu
|
- **In-App Menu**: [gives bars a fancy, dark look](https://user-images.githubusercontent.com/78568641/112215894-923dbf00-8c29-11eb-95c3-3ce15db27eca.png)
|
||||||
**: [gives bars a fancy, dark look](https://user-images.githubusercontent.com/78568641/112215894-923dbf00-8c29-11eb-95c3-3ce15db27eca.png)
|
|
||||||
|
|
||||||
> (see [this post](https://github.com/th-ch/youtube-music/issues/410#issuecomment-952060709) if you have problem
|
> (see [this post](https://github.com/th-ch/youtube-music/issues/410#issuecomment-952060709) if you have problem
|
||||||
accessing the menu after enabling this plugin and hide-menu option)
|
accessing the menu after enabling this plugin and hide-menu option)
|
||||||
|
|
||||||
- [**Last.fm**](https://www.last.fm/): Scrobbles support
|
- [**Last.fm**](https://www.last.fm/): Scrobbles support
|
||||||
|
|
||||||
|
- **Lumia Stream**: Adds [Lumia Stream](https://lumiastream.com/) support
|
||||||
|
|
||||||
- **Lyrics Genius**: Adds lyrics support for most songs
|
- **Lyrics Genius**: Adds lyrics support for most songs
|
||||||
|
|
||||||
- **Navigation**: Next/Back navigation arrows directly integrated in the interface, like in your favorite browser
|
- **Navigation**: Next/Back navigation arrows directly integrated in the interface, like in your favorite browser
|
||||||
@ -159,15 +189,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).
|
||||||
@ -179,8 +200,8 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/th-ch/youtube-music
|
git clone https://github.com/th-ch/youtube-music
|
||||||
cd youtube-music
|
cd youtube-music
|
||||||
npm
|
pnpm install --frozen-lockfile
|
||||||
npm run start
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build your own plugins
|
## Build your own plugins
|
||||||
@ -194,47 +215,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
|
||||||
@ -242,45 +286,67 @@ 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'));
|
export default createPlugin({
|
||||||
};
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // if value is true, ytmusic will show a 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
|
export default createPlugin({
|
||||||
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
name: 'Plugin Label',
|
||||||
};
|
restartNeeded: true, // if value is true, ytmusic will show the 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
|
||||||
|
|
||||||
1. Clone the repo
|
1. Clone the repo
|
||||||
2. Run `npm i` to install dependencies
|
2. Follow [this guide](https://pnpm.io/installation) to install `pnpm`
|
||||||
3. Run `npm run build:OS`
|
3. Run `pnpm install --frozen-lockfile` to install dependencies
|
||||||
|
4. Run `pnpm build:OS`
|
||||||
|
|
||||||
- `npm run build:win` - Windows
|
- `pnpm dist:win` - Windows
|
||||||
- `npm run build:linux` - Linux
|
- `pnpm dist:linux` - Linux
|
||||||
- `npm run build:mac` - MacOS
|
- `pnpm dist:mac` - MacOS
|
||||||
|
|
||||||
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
|
||||||
npm run test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Uses [Playwright](https://playwright.dev/) to test the app.
|
Uses [Playwright](https://playwright.dev/) to test the app.
|
||||||
@ -288,3 +354,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.
170
changelog.md
170
changelog.md
@ -2,8 +2,178 @@
|
|||||||
|
|
||||||
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.1.1](https://github.com/th-ch/youtube-music/compare/v3.1.0...v3.1.1)
|
||||||
|
|
||||||
|
- fix: fix renderer plugin load timing [`#1522`](https://github.com/th-ch/youtube-music/issues/1522)
|
||||||
|
- chore(i18n): Translated using Weblate (Lithuanian) [`fc1a7cd`](https://github.com/th-ch/youtube-music/commit/fc1a7cda62b6e33e5f5d57a5a6e0adef6a32bf9a)
|
||||||
|
- chore(i18n): Translated using Weblate (Chinese (Simplified)) [`eba7026`](https://github.com/th-ch/youtube-music/commit/eba7026b89bbfdd3ac07cf728a66ba9bdd274ec0)
|
||||||
|
- chore(deps): update dependency rollup to v4.8.0 [`a601d0b`](https://github.com/th-ch/youtube-music/commit/a601d0b3d2dee0fabad79a18e1a7dd0ca84ccf01)
|
||||||
|
|
||||||
|
#### [v3.1.0](https://github.com/th-ch/youtube-music/compare/v3.0.2...v3.1.0)
|
||||||
|
|
||||||
|
> 11 December 2023
|
||||||
|
|
||||||
|
- chore(deps): update dependency electron to v28 [`#1498`](https://github.com/th-ch/youtube-music/pull/1498)
|
||||||
|
- Enable/Disable Navigation without restart [`#1507`](https://github.com/th-ch/youtube-music/pull/1507)
|
||||||
|
- Turkish(tr)_lang_file [`#1513`](https://github.com/th-ch/youtube-music/pull/1513)
|
||||||
|
- Skip Disliked Songs [`#1505`](https://github.com/th-ch/youtube-music/pull/1505)
|
||||||
|
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.13.2 [`#1452`](https://github.com/th-ch/youtube-music/pull/1452)
|
||||||
|
- fix: Homebrew latest release url parsing [`#1496`](https://github.com/th-ch/youtube-music/pull/1496)
|
||||||
|
- fix: in-player adblocker inject timing issue [`#1478`](https://github.com/th-ch/youtube-music/issues/1478)
|
||||||
|
- fix(package.json): fix RPM version `libuuid` issue [`#1508`](https://github.com/th-ch/youtube-music/issues/1508)
|
||||||
|
- Translated using Weblate (Polish) [`7b78ba6`](https://github.com/th-ch/youtube-music/commit/7b78ba67613f14be65a45751efeb06431b405a91)
|
||||||
|
- Translated using Weblate (French) [`ebc0879`](https://github.com/th-ch/youtube-music/commit/ebc087963b23265ff00528c8305d51597abf587a)
|
||||||
|
- Translated using Weblate (Chinese (Traditional)) [`020bdc0`](https://github.com/th-ch/youtube-music/commit/020bdc0811ea45ad6c2853c62a05ae6695c5c4f9)
|
||||||
|
|
||||||
|
#### [v3.0.2](https://github.com/th-ch/youtube-music/compare/v3.0.1...v3.0.2)
|
||||||
|
|
||||||
|
> 3 December 2023
|
||||||
|
|
||||||
|
- fix(adblocker): fix In-Player adblocker [`#1478`](https://github.com/th-ch/youtube-music/issues/1478)
|
||||||
|
- fix(menu): crash on linux [`#1477`](https://github.com/th-ch/youtube-music/issues/1477)
|
||||||
|
- fix: update pnpm-lock.yaml [`9e2c6b1`](https://github.com/th-ch/youtube-music/commit/9e2c6b1afa33b5708853c8328946e68ec45b09c3)
|
||||||
|
- Translated using Weblate (Chinese (Traditional)) [`125b69f`](https://github.com/th-ch/youtube-music/commit/125b69fd75a05c3eb893886119e2d9f2332b3e56)
|
||||||
|
- Translated using Weblate (French) [`15c4551`](https://github.com/th-ch/youtube-music/commit/15c455105b5100a8ee2bd0a4631548d3d455f047)
|
||||||
|
|
||||||
|
#### [v3.0.1](https://github.com/th-ch/youtube-music/compare/v3.0.0...v3.0.1)
|
||||||
|
|
||||||
|
> 2 December 2023
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
> 27 October 2023
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
- Update changelog for v2.1.3 [`dc73561`](https://github.com/th-ch/youtube-music/commit/dc73561c8a8acfc8ba91aff2dc78e4267869f2fd)
|
||||||
|
- Bump version to 2.2.0 [`6288d0b`](https://github.com/th-ch/youtube-music/commit/6288d0b171a65ea015922cdf3af6c7bd9a1f269b)
|
||||||
|
|
||||||
|
#### [v2.1.3](https://github.com/th-ch/youtube-music/compare/v2.1.2...v2.1.3)
|
||||||
|
|
||||||
|
> 23 October 2023
|
||||||
|
|
||||||
|
- fix: fixed bugs in downloader [`#1342`](https://github.com/th-ch/youtube-music/pull/1342)
|
||||||
|
- feat(discord): rename `Listen Along` to `Play on YTM` [`#1341`](https://github.com/th-ch/youtube-music/issues/1341)
|
||||||
|
- chore(deps): bump deps [`4333891`](https://github.com/th-ch/youtube-music/commit/4333891ccabe42aedf756fd48618be715db13262)
|
||||||
|
- Update changelog for v2.1.2 [`fa4c69d`](https://github.com/th-ch/youtube-music/commit/fa4c69d228d4e06a7858e2b22fcdfa075a8ca766)
|
||||||
|
- fix(store): fix listenAlong statement [`bceaa05`](https://github.com/th-ch/youtube-music/commit/bceaa05197d47a4a4bbd22e767d1e4d6ec277514)
|
||||||
|
|
||||||
|
#### [v2.1.2](https://github.com/th-ch/youtube-music/compare/v2.1.1...v2.1.2)
|
||||||
|
|
||||||
|
> 19 October 2023
|
||||||
|
|
||||||
|
- feat(in-app-menu): add an option to hide the window controls [`#1335`](https://github.com/th-ch/youtube-music/pull/1335)
|
||||||
|
- fix: fixed an issue where the album name was missing [`#1334`](https://github.com/th-ch/youtube-music/pull/1334)
|
||||||
|
- chore(deps): update dependency electron to v27.0.1 [`#1331`](https://github.com/th-ch/youtube-music/pull/1331)
|
||||||
|
- fix: fixed an issue where only the first 100 songs in a playlist were downloaded [`#1329`](https://github.com/th-ch/youtube-music/pull/1329)
|
||||||
|
- Updated readme plugins list [`#1326`](https://github.com/th-ch/youtube-music/pull/1326)
|
||||||
|
- QOL: Move source code under the src directory. [`#1318`](https://github.com/th-ch/youtube-music/pull/1318)
|
||||||
|
- feat: migrate from `npm` to `pnpm` [`#1316`](https://github.com/th-ch/youtube-music/pull/1316)
|
||||||
|
- fix: fix unresponsive (fix #1325) [`#1325`](https://github.com/th-ch/youtube-music/issues/1325)
|
||||||
|
- fix(blocker): remove the `app.isPackaged` check (fix #1315) [`#1315`](https://github.com/th-ch/youtube-music/issues/1315)
|
||||||
|
- fix(discord): `Discord RPC fails if a song's title is only one character` (fix #1314) [`#1314`](https://github.com/th-ch/youtube-music/issues/1314)
|
||||||
|
- chore(deps): Bump @rollup/plugin-commonjs, pnpm version, Remove ytpl [`9705f84`](https://github.com/th-ch/youtube-music/commit/9705f8489d7bf262bfd8b15ab84c2d3485f10eae)
|
||||||
|
- chore(deps): Bump rollup, @xhayper/discord-rpc version [`00a3e8d`](https://github.com/th-ch/youtube-music/commit/00a3e8d35ec335e1913be19f30ae09dbe0b7acdd)
|
||||||
|
- chore(deps): update dependency rollup to v4.1.4 [`6774d54`](https://github.com/th-ch/youtube-music/commit/6774d54f5eca432edc2e11743d9d1b1c2fda9ac8)
|
||||||
|
|
||||||
|
#### [v2.1.1](https://github.com/th-ch/youtube-music/compare/v2.1.0...v2.1.1)
|
||||||
|
|
||||||
|
> 14 October 2023
|
||||||
|
|
||||||
|
- hotfix(downloader): can't get an album title (fix #1313) [`#1313`](https://github.com/th-ch/youtube-music/issues/1313)
|
||||||
|
- Update changelog for v2.1.0 [`92cab89`](https://github.com/th-ch/youtube-music/commit/92cab89d17175741e60e65ea61633e23ebdc1f45)
|
||||||
|
- Bump version to 2.1.1 [`3bb5bc2`](https://github.com/th-ch/youtube-music/commit/3bb5bc2ca1856f4e222ee1e01e865f1ab804fdba)
|
||||||
|
- Add "about" menu to show app version [`21c45fa`](https://github.com/th-ch/youtube-music/commit/21c45faf2043cf72a7c14d5cf6c8d848d0448528)
|
||||||
|
|
||||||
|
#### [v2.1.0](https://github.com/th-ch/youtube-music/compare/v2.0.4...v2.1.0)
|
||||||
|
|
||||||
|
> 14 October 2023
|
||||||
|
|
||||||
|
- feat(downloader): Added support for audio format auto-detection [`#1310`](https://github.com/th-ch/youtube-music/pull/1310)
|
||||||
|
- feat(in-app-menu): enable in-app-menu by default (in Windows) [`#1311`](https://github.com/th-ch/youtube-music/pull/1311)
|
||||||
|
- fix: winget publish [`#1307`](https://github.com/th-ch/youtube-music/pull/1307)
|
||||||
|
- hotfix(downloader): fix invalid query selector (fix #1308) [`#1308`](https://github.com/th-ch/youtube-music/issues/1308)
|
||||||
|
- chore(deps): bump dependencies [`3c6b3ae`](https://github.com/th-ch/youtube-music/commit/3c6b3aeff0aae32adb2f2ad9c091b0a9701d3c24)
|
||||||
|
- chore(actions): create winget-cla.yml [`37181a7`](https://github.com/th-ch/youtube-music/commit/37181a7b5e2aa5bed6a36298eac3a66aac2762b8)
|
||||||
|
- Update changelog for v2.0.4 [`e9398ad`](https://github.com/th-ch/youtube-music/commit/e9398adac34a8abb11801e32999a915a8be0ece6)
|
||||||
|
|
||||||
|
#### [v2.0.4](https://github.com/th-ch/youtube-music/compare/v2.0.3...v2.0.4)
|
||||||
|
|
||||||
|
> 12 October 2023
|
||||||
|
|
||||||
|
- hotfix(adblocker): fix `ipcRenderer.sendSync() with ...` [`#1301`](https://github.com/th-ch/youtube-music/pull/1301)
|
||||||
|
- fix(downloader): Korean filename is broken on non-macOS devices [`#1297`](https://github.com/th-ch/youtube-music/pull/1297)
|
||||||
|
- chore(deps): bump deps [`b6894dc`](https://github.com/th-ch/youtube-music/commit/b6894dca2974c63fa2945d3a4995665d11eb2a78)
|
||||||
|
- fix: bump dependencies [`7aa970c`](https://github.com/th-ch/youtube-music/commit/7aa970cebc8e1407ff6937b402ba303e14c73efd)
|
||||||
|
- fix(downloader): private playlist download [`1d5b299`](https://github.com/th-ch/youtube-music/commit/1d5b2997bd0c72c1c007c57b145509e4a8f77fef)
|
||||||
|
|
||||||
|
#### [v2.0.3](https://github.com/th-ch/youtube-music/compare/v2.0.2...v2.0.3)
|
||||||
|
|
||||||
|
> 10 October 2023
|
||||||
|
|
||||||
|
- feat(discord): add `Hide GitHub link Button` [`#1293`](https://github.com/th-ch/youtube-music/pull/1293)
|
||||||
|
- feat(deps): bundle `youtubei.js` (temporary solution) [`#1292`](https://github.com/th-ch/youtube-music/pull/1292)
|
||||||
|
- fix(mpris): fixed an issue where MPRIS information was incorrect [`#1291`](https://github.com/th-ch/youtube-music/pull/1291)
|
||||||
|
- fix(discord): fixed an issue where `timeChanged` was not being applied to Discord activities [`#1290`](https://github.com/th-ch/youtube-music/pull/1290)
|
||||||
|
- Fix: typo in README [`#1286`](https://github.com/th-ch/youtube-music/pull/1286)
|
||||||
|
- fix: chore(deps): update dependency @jellybrick/mpris-service to 2.1.4 (fix #971) [`#971`](https://github.com/th-ch/youtube-music/issues/971)
|
||||||
|
- chore(deps): Bump `@cliqz/adblocker-electron` to 1.26.8 (fix #1269) [`#1269`](https://github.com/th-ch/youtube-music/issues/1269)
|
||||||
|
- fix: missing icons taskbar-mediacontrol [`fbf4b3b`](https://github.com/th-ch/youtube-music/commit/fbf4b3b8b5e39c61975e67efc990c45f62de76d8)
|
||||||
|
- remove: migration scripts [`52ba2dc`](https://github.com/th-ch/youtube-music/commit/52ba2dc9ffd8e235251d1279686f55e33b3fa3bb)
|
||||||
|
- feat: add migration script [`926b9fb`](https://github.com/th-ch/youtube-music/commit/926b9fb5e6db69b69935ec5d7be9a76a84e54ceb)
|
||||||
|
|
||||||
|
#### [v2.0.2](https://github.com/th-ch/youtube-music/compare/v2.0.1...v2.0.2)
|
||||||
|
|
||||||
|
> 8 October 2023
|
||||||
|
|
||||||
|
- fix: discord-rpc [`#1278`](https://github.com/th-ch/youtube-music/pull/1278)
|
||||||
|
- Bump version to 2.0.2 [`b5dbfaf`](https://github.com/th-ch/youtube-music/commit/b5dbfaf68691a546d72f5c1818fd3a44802eb0fa)
|
||||||
|
- Merge pull request #1272 from th-ch/feat/resolves-1265 [`6b7fd5b`](https://github.com/th-ch/youtube-music/commit/6b7fd5ba630888de08004105179c059c6d93e028)
|
||||||
|
- Merge pull request #1279 from th-ch/fix/1274 [`73a049a`](https://github.com/th-ch/youtube-music/commit/73a049a7bc5161f0d53c252cf510f1e2a6f6eeb3)
|
||||||
|
|
||||||
#### [v2.0.1](https://github.com/th-ch/youtube-music/compare/v2.0.0...v2.0.1)
|
#### [v2.0.1](https://github.com/th-ch/youtube-music/compare/v2.0.0...v2.0.1)
|
||||||
|
|
||||||
|
> 8 October 2023
|
||||||
|
|
||||||
- Update changelog for v2.0.0 [`2d69dfd`](https://github.com/th-ch/youtube-music/commit/2d69dfd333c3223ecc7de13a0abc98fd99aa3a2b)
|
- Update changelog for v2.0.0 [`2d69dfd`](https://github.com/th-ch/youtube-music/commit/2d69dfd333c3223ecc7de13a0abc98fd99aa3a2b)
|
||||||
- hotfix: hotfix for #1267 [`c002263`](https://github.com/th-ch/youtube-music/commit/c002263c3bdd51890b8ffb431283afb60405d8fe)
|
- hotfix: hotfix for #1267 [`c002263`](https://github.com/th-ch/youtube-music/commit/c002263c3bdd51890b8ffb431283afb60405d8fe)
|
||||||
- Bump version to 2.0.1 [`a1f025e`](https://github.com/th-ch/youtube-music/commit/a1f025e23c599fe5eb63b32ea38ee81200d232d6)
|
- Bump version to 2.0.1 [`a1f025e`](https://github.com/th-ch/youtube-music/commit/a1f025e23c599fe5eb63b32ea38ee81200d232d6)
|
||||||
|
|||||||
@ -1,279 +0,0 @@
|
|||||||
import { blockers } from '../plugins/adblocker/blocker-types';
|
|
||||||
|
|
||||||
export interface WindowSizeConfig {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DefaultConfig {
|
|
||||||
'window-size': {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
'window-maximized': boolean;
|
|
||||||
'window-position': {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
url: string;
|
|
||||||
options: {
|
|
||||||
tray: boolean;
|
|
||||||
appVisible: boolean;
|
|
||||||
autoUpdates: boolean;
|
|
||||||
alwaysOnTop: boolean;
|
|
||||||
hideMenu: boolean;
|
|
||||||
hideMenuWarned: boolean;
|
|
||||||
startAtLogin: boolean;
|
|
||||||
disableHardwareAcceleration: boolean;
|
|
||||||
removeUpgradeButton: boolean;
|
|
||||||
restartOnConfigChanges: boolean;
|
|
||||||
trayClickPlayPause: boolean;
|
|
||||||
autoResetAppCache: boolean;
|
|
||||||
resumeOnStart: boolean;
|
|
||||||
likeButtons: string;
|
|
||||||
proxy: string;
|
|
||||||
startingPage: string;
|
|
||||||
overrideUserAgent: boolean;
|
|
||||||
themes: string[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultConfig = {
|
|
||||||
'window-size': {
|
|
||||||
width: 1100,
|
|
||||||
height: 550,
|
|
||||||
},
|
|
||||||
'window-maximized': false,
|
|
||||||
'window-position': {
|
|
||||||
x: -1,
|
|
||||||
y: -1,
|
|
||||||
},
|
|
||||||
'url': 'https://music.youtube.com',
|
|
||||||
'options': {
|
|
||||||
tray: false,
|
|
||||||
appVisible: true,
|
|
||||||
autoUpdates: true,
|
|
||||||
alwaysOnTop: false,
|
|
||||||
hideMenu: false,
|
|
||||||
hideMenuWarned: false,
|
|
||||||
startAtLogin: false,
|
|
||||||
disableHardwareAcceleration: false,
|
|
||||||
removeUpgradeButton: false,
|
|
||||||
restartOnConfigChanges: false,
|
|
||||||
trayClickPlayPause: false,
|
|
||||||
autoResetAppCache: false,
|
|
||||||
resumeOnStart: true,
|
|
||||||
likeButtons: '',
|
|
||||||
proxy: '',
|
|
||||||
startingPage: '',
|
|
||||||
overrideUserAgent: false,
|
|
||||||
themes: [] as string[],
|
|
||||||
},
|
|
||||||
/** 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': {},
|
|
||||||
'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
|
|
||||||
listenAlong: true, // Add a "listen along" button to rich presence
|
|
||||||
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
|
|
||||||
},
|
|
||||||
'downloader': {
|
|
||||||
enabled: false,
|
|
||||||
ffmpegArgs: ['-b:a', '256k'], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
|
|
||||||
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
|
|
||||||
preset: 'mp3',
|
|
||||||
skipExisting: false,
|
|
||||||
playlistMaxItems: undefined as number | undefined,
|
|
||||||
},
|
|
||||||
'exponential-volume': {},
|
|
||||||
'in-app-menu': {},
|
|
||||||
'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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defaultConfig;
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import Store from 'electron-store';
|
|
||||||
|
|
||||||
import defaultConfig from './defaults';
|
|
||||||
import plugins from './plugins';
|
|
||||||
import store from './store';
|
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
|
||||||
|
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => {
|
|
||||||
store.set(key, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
function setMenuOption(key: string, value: unknown) {
|
|
||||||
set(key, value);
|
|
||||||
if (store.get('options.restartOnConfigChanges')) {
|
|
||||||
restart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MAGIC OF TYPESCRIPT
|
|
||||||
|
|
||||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 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;
|
|
||||||
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 PathValue<T, K extends string> =
|
|
||||||
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
|
|
||||||
? 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 {
|
|
||||||
defaultConfig,
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
setMenuOption,
|
|
||||||
edit: () => store.openInEditor(),
|
|
||||||
watch(cb: Parameters<Store['onDidChange']>[1]) {
|
|
||||||
store.onDidChange('options', cb);
|
|
||||||
store.onDidChange('plugins', cb);
|
|
||||||
},
|
|
||||||
plugins,
|
|
||||||
};
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import store from './store';
|
|
||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
interface Plugin {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin];
|
|
||||||
return pluginConfig !== undefined && pluginConfig.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOptions<T>(plugin: string, options: T) {
|
|
||||||
const plugins = store.get('plugins') as Record<string, T>;
|
|
||||||
store.set('plugins', {
|
|
||||||
...plugins,
|
|
||||||
[plugin]: {
|
|
||||||
...plugins[plugin],
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setMenuOptions<T>(plugin: string, options: T) {
|
|
||||||
setOptions(plugin, options);
|
|
||||||
if (store.get('options.restartOnConfigChanges')) {
|
|
||||||
restart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOptions<T>(plugin: string): T {
|
|
||||||
return (store.get('plugins') as Record<string, T>)[plugin];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enable(plugin: string) {
|
|
||||||
setMenuOptions(plugin, { enabled: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disable(plugin: string) {
|
|
||||||
setMenuOptions(plugin, { enabled: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
isEnabled,
|
|
||||||
getEnabled,
|
|
||||||
enable,
|
|
||||||
disable,
|
|
||||||
setOptions,
|
|
||||||
setMenuOptions,
|
|
||||||
getOptions,
|
|
||||||
};
|
|
||||||
124
config/store.ts
124
config/store.ts
@ -1,124 +0,0 @@
|
|||||||
import Store from 'electron-store';
|
|
||||||
import Conf from 'conf';
|
|
||||||
|
|
||||||
import defaults from './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 = {
|
|
||||||
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
setDefaultPluginOptions(store, 'visualizer');
|
|
||||||
|
|
||||||
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
|
||||||
const pluginOptions = store.get('plugins.notifications') || {};
|
|
||||||
store.set('plugins.notifications', {
|
|
||||||
...defaults.plugins.notifications,
|
|
||||||
...pluginOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.get('options.ForceShowLikeButtons')) {
|
|
||||||
store.delete('options.ForceShowLikeButtons');
|
|
||||||
store.set('options.likeButtons', 'force');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
setDefaultPluginOptions(store, 'picture-in-picture');
|
|
||||||
|
|
||||||
if (store.get('plugins.video-toggle.mode') === undefined) {
|
|
||||||
store.set('plugins.video-toggle.mode', 'custom');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.14.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
if (
|
|
||||||
typeof store.get('plugins.precise-volume.globalShortcuts') !== 'object'
|
|
||||||
) {
|
|
||||||
store.set('plugins.precise-volume.globalShortcuts', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.get('plugins.hide-video-player.enabled')) {
|
|
||||||
store.delete('plugins.hide-video-player');
|
|
||||||
store.set('plugins.video-toggle.enabled', true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.13.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
if (store.get('plugins.discord.listenAlong') === undefined) {
|
|
||||||
store.set('plugins.discord.listenAlong', true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
const options = store.get('plugins.shortcuts') as Record<string, {
|
|
||||||
action: string;
|
|
||||||
shortcut: unknown;
|
|
||||||
}[] | Record<string, unknown>>;
|
|
||||||
let updated = false;
|
|
||||||
for (const optionType of ['global', 'local']) {
|
|
||||||
if (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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
store.set('plugins.shortcuts', options);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
if (store.get('options.resumeOnStart') === undefined) {
|
|
||||||
store.set('options.resumeOnStart', true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'>=1.7.0'(store: Conf<Record<string, unknown>>) {
|
|
||||||
const enabledPlugins = store.get('plugins') as string[];
|
|
||||||
if (!Array.isArray(enabledPlugins)) {
|
|
||||||
console.warn('Plugins are not in array format, cannot migrate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include custom options
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const plugins: Record<string, any> = {
|
|
||||||
adblocker: {
|
|
||||||
enabled: true,
|
|
||||||
cache: true,
|
|
||||||
additionalBlockLists: [],
|
|
||||||
},
|
|
||||||
downloader: {
|
|
||||||
enabled: false,
|
|
||||||
ffmpegArgs: [], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
|
|
||||||
downloadFolder: undefined, // Custom download folder (absolute path)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const enabledPlugin of enabledPlugins) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
plugins[enabledPlugin] = {
|
|
||||||
...plugins[enabledPlugin],
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
store.set('plugins', plugins);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default new Store({
|
|
||||||
defaults,
|
|
||||||
clearInvalidConfig: false,
|
|
||||||
migrations,
|
|
||||||
});
|
|
||||||
327
docs/readme/README-ko.md
Normal file
327
docs/readme/README-ko.md
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# 유튜브 뮤직 (YouTube Music)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
||||||
|
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
||||||
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
||||||
|
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<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';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // 나의 커스텀 config
|
||||||
|
stylesheets: [style], // 나의 커스텀 스타일
|
||||||
|
renderer() {} // 렌더러 훅 정의
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- HTML을 변경하려는 경우:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default 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> [백틱] 키)로 메뉴를 표시할 수 있습니다.
|
||||||
158
electron.vite.config.mts
Normal file
158
electron.vite.config.mts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
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.mjs';
|
||||||
|
import pluginLoader from './vite-plugins/plugin-loader.mjs';
|
||||||
|
|
||||||
|
import type { UserConfig } from 'vite';
|
||||||
|
import { i18nImporter } from './vite-plugins/i18n-importer.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
50
error.html
50
error.html
@ -1,50 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<title>Cannot load YouTube Music</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Roboto, Arial, sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
margin-right: -50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: #065fd4;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: white;
|
|
||||||
font: inherit;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: normal;
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 22px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<p>Cannot load YouTube Music… Internet disconnected?</p>
|
|
||||||
<a class="button" href="#" onclick="reload()">Retry</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
642
index.ts
642
index.ts
@ -1,642 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
|
|
||||||
import enhanceWebRequest from 'electron-better-web-request';
|
|
||||||
import is from 'electron-is';
|
|
||||||
import unhandled from 'electron-unhandled';
|
|
||||||
import { autoUpdater } from 'electron-updater';
|
|
||||||
import electronDebug from 'electron-debug';
|
|
||||||
|
|
||||||
import { BetterWebRequest } from 'electron-better-web-request/lib/electron-better-web-request';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
import { 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 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 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';
|
|
||||||
|
|
||||||
// Catch errors and log them
|
|
||||||
unhandled({
|
|
||||||
logger: console.error,
|
|
||||||
showDialog: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable Node options if the env var is set
|
|
||||||
process.env.NODE_OPTIONS = '';
|
|
||||||
|
|
||||||
// Prevent window being garbage collected
|
|
||||||
let mainWindow: Electron.BrowserWindow | null;
|
|
||||||
autoUpdater.autoDownload = false;
|
|
||||||
|
|
||||||
const gotTheLock = app.requestSingleInstanceLock();
|
|
||||||
if (!gotTheLock) {
|
|
||||||
app.exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
|
||||||
// OverlayScrollbar: Required for overlay scrollbars
|
|
||||||
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
|
|
||||||
if (config.get('options.disableHardwareAcceleration')) {
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log('Disabling hardware acceleration');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.disableHardwareAcceleration();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is.linux() && config.plugins.isEnabled('shortcuts')) {
|
|
||||||
// Stops chromium from launching its own MPRIS service
|
|
||||||
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.get('options.proxy')) {
|
|
||||||
app.commandLine.appendSwitch('proxy-server', config.get('options.proxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds debug features like hotkeys for triggering dev tools and reload
|
|
||||||
electronDebug({
|
|
||||||
showDevTools: false, // Disable automatic devTools on new window
|
|
||||||
});
|
|
||||||
|
|
||||||
let icon = 'assets/youtube-music.png';
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
icon = 'assets/generated/icon.ico';
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
icon = 'assets/generated/icon.icns';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClosed() {
|
|
||||||
// Dereference the window
|
|
||||||
// For multiple Windows store them in an array
|
|
||||||
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,
|
|
||||||
'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));
|
|
||||||
|
|
||||||
function loadPlugins(win: BrowserWindow) {
|
|
||||||
injectCSS(win.webContents, youtubeMusicCSS);
|
|
||||||
// Load user CSS
|
|
||||||
const themes: string[] = config.get('options.themes');
|
|
||||||
if (Array.isArray(themes)) {
|
|
||||||
for (const cssFile of themes) {
|
|
||||||
fileExists(
|
|
||||||
cssFile,
|
|
||||||
() => {
|
|
||||||
injectCSSAsFile(win.webContents, cssFile);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
console.warn(`CSS file "${cssFile}" does not exist, ignoring`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
win.webContents.once('did-finish-load', () => {
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log('did finish load');
|
|
||||||
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) {
|
|
||||||
handler(win, options as never);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load plugin "${plugin}"`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMainWindow() {
|
|
||||||
const windowSize = config.get('window-size');
|
|
||||||
const windowMaximized = config.get('window-maximized');
|
|
||||||
const windowPosition: Electron.Point = config.get('window-position');
|
|
||||||
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
icon,
|
|
||||||
width: windowSize.width,
|
|
||||||
height: windowSize.height,
|
|
||||||
backgroundColor: '#000',
|
|
||||||
show: false,
|
|
||||||
webPreferences: {
|
|
||||||
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
|
|
||||||
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
|
|
||||||
contextIsolation: false,
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
nodeIntegrationInSubFrames: true,
|
|
||||||
...(isTesting()
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
// Sandbox is only enabled in tests for now
|
|
||||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
|
||||||
sandbox: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
frame: !is.macOS() && !useInlineMenu,
|
|
||||||
titleBarOverlay: {
|
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#ffffff',
|
|
||||||
height: 36,
|
|
||||||
},
|
|
||||||
titleBarStyle: useInlineMenu
|
|
||||||
? 'hidden'
|
|
||||||
: (is.macOS()
|
|
||||||
? 'hiddenInset'
|
|
||||||
: 'default'),
|
|
||||||
autoHideMenuBar: config.get('options.hideMenu'),
|
|
||||||
});
|
|
||||||
loadPlugins(win);
|
|
||||||
|
|
||||||
if (windowPosition) {
|
|
||||||
const { x: windowX, y: windowY } = windowPosition;
|
|
||||||
const winSize = win.getSize();
|
|
||||||
const displaySize
|
|
||||||
= screen.getDisplayNearestPoint(windowPosition).bounds;
|
|
||||||
if (
|
|
||||||
windowX + winSize[0] < displaySize.x - 8
|
|
||||||
|| windowX - winSize[0] > displaySize.x + displaySize.width
|
|
||||||
|| windowY < displaySize.y - 8
|
|
||||||
|| windowY > displaySize.y + displaySize.height
|
|
||||||
) {
|
|
||||||
// Window is offscreen
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log(
|
|
||||||
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
win.setPosition(windowX, windowY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (windowMaximized) {
|
|
||||||
win.maximize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.get('options.alwaysOnTop')) {
|
|
||||||
win.setAlwaysOnTop(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlToLoad = config.get('options.resumeOnStart')
|
|
||||||
? config.get('url')
|
|
||||||
: config.defaultConfig.url;
|
|
||||||
win.webContents.loadURL(urlToLoad);
|
|
||||||
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', () => {
|
|
||||||
if (win.isMaximized()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = win.getPosition();
|
|
||||||
const isPiPEnabled: boolean
|
|
||||||
= 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;
|
|
||||||
|
|
||||||
win.on('resize', () => {
|
|
||||||
const windowSize = win.getSize();
|
|
||||||
const isMaximized = win.isMaximized();
|
|
||||||
|
|
||||||
const isPiPEnabled
|
|
||||||
= config.plugins.isEnabled('picture-in-picture')
|
|
||||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
|
||||||
|
|
||||||
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
|
|
||||||
winWasMaximized = isMaximized;
|
|
||||||
config.set('window-maximized', isMaximized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMaximized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPiPEnabled) {
|
|
||||||
lateSave('window-size', {
|
|
||||||
width: windowSize[0],
|
|
||||||
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> = {};
|
|
||||||
|
|
||||||
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
|
|
||||||
if (savedTimeouts[key]) {
|
|
||||||
clearTimeout(savedTimeouts[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
savedTimeouts[key] = setTimeout(() => {
|
|
||||||
fn(key, value);
|
|
||||||
savedTimeouts[key] = undefined;
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('render-process-gone', (event, webContents, details) => {
|
|
||||||
showUnresponsiveDialog(win, details);
|
|
||||||
});
|
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
|
||||||
if (config.get('options.appVisible')) {
|
|
||||||
win.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
removeContentSecurityPolicy();
|
|
||||||
|
|
||||||
return win;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.once('browser-window-created', (event, win) => {
|
|
||||||
if (config.get('options.overrideUserAgent')) {
|
|
||||||
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
|
||||||
const originalUserAgent = win.webContents.userAgent;
|
|
||||||
const userAgents = {
|
|
||||||
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',
|
|
||||||
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedUserAgent
|
|
||||||
= is.macOS() ? userAgents.mac
|
|
||||||
: (is.windows() ? userAgents.windows
|
|
||||||
: userAgents.linux);
|
|
||||||
|
|
||||||
win.webContents.userAgent = updatedUserAgent;
|
|
||||||
app.userAgentFallback = updatedUserAgent;
|
|
||||||
|
|
||||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
|
||||||
// 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')) {
|
|
||||||
details.requestHeaders['User-Agent'] = originalUserAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb({ requestHeaders: details.requestHeaders });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSongInfo(win);
|
|
||||||
setupAppControls();
|
|
||||||
|
|
||||||
win.webContents.on('did-fail-load', (
|
|
||||||
_event,
|
|
||||||
errorCode,
|
|
||||||
errorDescription,
|
|
||||||
validatedURL,
|
|
||||||
isMainFrame,
|
|
||||||
frameProcessId,
|
|
||||||
frameRoutingId,
|
|
||||||
) => {
|
|
||||||
const log = JSON.stringify({
|
|
||||||
error: 'did-fail-load',
|
|
||||||
errorCode,
|
|
||||||
errorDescription,
|
|
||||||
validatedURL,
|
|
||||||
isMainFrame,
|
|
||||||
frameProcessId,
|
|
||||||
frameRoutingId,
|
|
||||||
}, null, '\t');
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(config.plugins.isEnabled('in-app-menu') && errorCode === -3)) { // -3 is a false positive with in-app-menu
|
|
||||||
win.webContents.send('log', log);
|
|
||||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
win.webContents.on('will-prevent-unload', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregister all shortcuts.
|
|
||||||
globalShortcut.unregisterAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
|
||||||
// On OS X it's common to re-create a window in the app when the
|
|
||||||
// dock icon is clicked and there are no other windows open.
|
|
||||||
if (mainWindow === null) {
|
|
||||||
mainWindow = createMainWindow();
|
|
||||||
} else if (!mainWindow.isVisible()) {
|
|
||||||
mainWindow.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('ready', () => {
|
|
||||||
if (config.get('options.autoResetAppCache')) {
|
|
||||||
// Clear cache after 20s
|
|
||||||
const clearCacheTimeout = setTimeout(() => {
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log('Clearing app cache.');
|
|
||||||
}
|
|
||||||
|
|
||||||
session.defaultSession.clearCache();
|
|
||||||
clearTimeout(clearCacheTimeout);
|
|
||||||
}, 20_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register appID on windows
|
|
||||||
if (is.windows()) {
|
|
||||||
const appID = 'com.github.th-ch.youtube-music';
|
|
||||||
app.setAppUserModelId(appID);
|
|
||||||
const appLocation = process.execPath;
|
|
||||||
const appData = app.getPath('appData');
|
|
||||||
// Check shortcut validity if not in dev mode / running portable app
|
|
||||||
if (!is.dev() && !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
|
|
||||||
if (
|
|
||||||
shortcutDetails.target !== appLocation
|
|
||||||
|| shortcutDetails.appUserModelId !== appID
|
|
||||||
) {
|
|
||||||
throw 'needUpdate';
|
|
||||||
}
|
|
||||||
} catch (error) { // If not valid -> Register shortcut
|
|
||||||
shell.writeShortcutLink(
|
|
||||||
shortcutPath,
|
|
||||||
error === 'needUpdate' ? 'update' : 'create',
|
|
||||||
{
|
|
||||||
target: appLocation,
|
|
||||||
cwd: path.dirname(appLocation),
|
|
||||||
description: 'YouTube Music Desktop App - including custom plugins',
|
|
||||||
appUserModelId: appID,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow = createMainWindow();
|
|
||||||
setApplicationMenu(mainWindow);
|
|
||||||
setUpTray(app, mainWindow);
|
|
||||||
|
|
||||||
setupProtocolHandler(mainWindow);
|
|
||||||
|
|
||||||
app.on('second-instance', (_, commandLine) => {
|
|
||||||
const uri = `${APP_PROTOCOL}://`;
|
|
||||||
const protocolArgv = commandLine.find((arg) => arg.startsWith(uri));
|
|
||||||
if (protocolArgv) {
|
|
||||||
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
|
|
||||||
const command = protocolArgv.slice(uri.length, lastIndex);
|
|
||||||
if (is.dev()) {
|
|
||||||
console.debug(`Received command over protocol: "${command}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleProtocol(command);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mainWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainWindow.isMinimized()) {
|
|
||||||
mainWindow.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mainWindow.isVisible()) {
|
|
||||||
mainWindow.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Autostart at login
|
|
||||||
app.setLoginItemSettings({
|
|
||||||
openAtLogin: config.get('options.startAtLogin'),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!is.dev() && config.get('options.autoUpdates')) {
|
|
||||||
const updateTimeout = setTimeout(() => {
|
|
||||||
autoUpdater.checkForUpdatesAndNotify();
|
|
||||||
clearTimeout(updateTimeout);
|
|
||||||
}, 2000);
|
|
||||||
autoUpdater.on('update-available', () => {
|
|
||||||
const downloadLink
|
|
||||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
|
||||||
const dialogOptions: Electron.MessageBoxOptions = {
|
|
||||||
type: 'info',
|
|
||||||
buttons: ['OK', 'Download', 'Disable updates'],
|
|
||||||
title: 'Application Update',
|
|
||||||
message: 'A new version is available',
|
|
||||||
detail: `A new version is available and can be downloaded at ${downloadLink}`,
|
|
||||||
};
|
|
||||||
dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
|
|
||||||
switch (dialogOutput.response) {
|
|
||||||
// Download
|
|
||||||
case 1: {
|
|
||||||
shell.openExternal(downloadLink);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable updates
|
|
||||||
case 2: {
|
|
||||||
config.set('options.autoUpdates', false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
|
||||||
dialog.showMessageBox(mainWindow, {
|
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
|
||||||
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
|
||||||
});
|
|
||||||
config.set('options.hideMenuWarned', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimized for Mac OS X
|
|
||||||
if (is.macOS() && !config.get('options.appVisible')) {
|
|
||||||
app.dock.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
let forceQuit = false;
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
forceQuit = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (is.macOS() || config.get('options.tray')) {
|
|
||||||
mainWindow.on('close', (event) => {
|
|
||||||
// Hide the window instead of quitting (quit is available in tray options)
|
|
||||||
if (!forceQuit) {
|
|
||||||
event.preventDefault();
|
|
||||||
mainWindow!.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
|
|
||||||
if (details) {
|
|
||||||
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.showMessageBox(win, {
|
|
||||||
type: 'error',
|
|
||||||
title: 'Window Unresponsive',
|
|
||||||
message: 'The Application is Unresponsive',
|
|
||||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
|
||||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
|
||||||
cancelId: 0,
|
|
||||||
}).then((result) => {
|
|
||||||
switch (result.response) {
|
|
||||||
case 1: {
|
|
||||||
restart();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 2: {
|
|
||||||
app.quit();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// HACK: electron-better-web-request's typing is wrong
|
|
||||||
type BetterSession = Omit<Electron.Session, 'webRequest'> & { webRequest: BetterWebRequest & Electron.WebRequest };
|
|
||||||
function removeContentSecurityPolicy(
|
|
||||||
betterSession: BetterSession = session.defaultSession as BetterSession,
|
|
||||||
) {
|
|
||||||
// Allows defining multiple "onHeadersReceived" listeners
|
|
||||||
// by enhancing the session.
|
|
||||||
// Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener
|
|
||||||
enhanceWebRequest(betterSession);
|
|
||||||
|
|
||||||
// Custom listener to tweak the content security policy
|
|
||||||
betterSession.webRequest.onHeadersReceived((details, callback) => {
|
|
||||||
details.responseHeaders ??= {};
|
|
||||||
|
|
||||||
// Remove the content security policy
|
|
||||||
delete details.responseHeaders['content-security-policy-report-only'];
|
|
||||||
delete details.responseHeaders['content-security-policy'];
|
|
||||||
|
|
||||||
callback({ cancel: false, responseHeaders: details.responseHeaders });
|
|
||||||
});
|
|
||||||
|
|
||||||
type ResolverListener = { apply: () => Promise<Record<string, unknown>>; context: unknown };
|
|
||||||
// When multiple listeners are defined, apply them all
|
|
||||||
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners: ResolverListener[]) => {
|
|
||||||
return listeners.reduce<Promise<Record<string, unknown>>>(
|
|
||||||
async (accumulator: Promise<Record<string, unknown>>, listener: ResolverListener) => {
|
|
||||||
const acc = await accumulator;
|
|
||||||
if (acc.cancel) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await listener.apply();
|
|
||||||
return { ...accumulator, ...result };
|
|
||||||
},
|
|
||||||
Promise.resolve({ cancel: false }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
472
menu.ts
472
menu.ts
@ -1,472 +0,0 @@
|
|||||||
import is from 'electron-is';
|
|
||||||
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
|
|
||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { restart } from './providers/app-controls';
|
|
||||||
import config from './config';
|
|
||||||
import { startingPages } from './providers/extracted-data';
|
|
||||||
import promptOptions from './providers/prompt-options';
|
|
||||||
|
|
||||||
import adblockerMenu from './plugins/adblocker/menu';
|
|
||||||
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 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[];
|
|
||||||
|
|
||||||
// True only if in-app-menu was loaded on launch
|
|
||||||
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
|
||||||
|
|
||||||
const betaPlugins = ['crossfade', 'lumiastream'];
|
|
||||||
|
|
||||||
const pluginMenus = {
|
|
||||||
'adblocker': adblockerMenu,
|
|
||||||
'disable-autoplay': disableAutoplayMenu,
|
|
||||||
'captions-selector': captionsSelectorMenu,
|
|
||||||
'crossfade': crossfadeMenu,
|
|
||||||
'discord': discordMenu,
|
|
||||||
'downloader': downloaderMenu,
|
|
||||||
'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,
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.plugins.isEnabled(plugin),
|
|
||||||
click(item: Electron.MenuItem) {
|
|
||||||
if (item.checked) {
|
|
||||||
config.plugins.enable(plugin);
|
|
||||||
} else {
|
|
||||||
config.plugins.disable(plugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSubmenu) {
|
|
||||||
refreshMenu?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|
||||||
const refreshMenu = () => {
|
|
||||||
setApplicationMenu(win);
|
|
||||||
if (inAppMenuActive) {
|
|
||||||
win.webContents.send('refreshMenu');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Plugins',
|
|
||||||
submenu:
|
|
||||||
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, refreshMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: pluginLabel,
|
|
||||||
submenu: [
|
|
||||||
pluginEnabledMenu(pluginName, 'Enabled', true, refreshMenu),
|
|
||||||
{ type: 'separator' },
|
|
||||||
...getPluginMenu(win, config.plugins.getOptions(pluginName), refreshMenu),
|
|
||||||
],
|
|
||||||
} satisfies Electron.MenuItemConstructorOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pluginEnabledMenu(pluginName, pluginLabel);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Options',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Auto-update',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.autoUpdates'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.autoUpdates', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Resume last song when app starts',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.resumeOnStart'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.resumeOnStart', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Starting page',
|
|
||||||
submenu: (() => {
|
|
||||||
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
|
|
||||||
label: name,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.startingPage') === name,
|
|
||||||
click() {
|
|
||||||
config.set('options.startingPage', name);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
subMenuArray.unshift({
|
|
||||||
label: 'Unset',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.startingPage') === '',
|
|
||||||
click() {
|
|
||||||
config.set('options.startingPage', '');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return subMenuArray;
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Visual Tweaks',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Remove upgrade button',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.removeUpgradeButton'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.removeUpgradeButton', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Like buttons',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Default',
|
|
||||||
type: 'radio',
|
|
||||||
checked: !config.get('options.likeButtons'),
|
|
||||||
click() {
|
|
||||||
config.set('options.likeButtons', '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Force show',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.likeButtons') === 'force',
|
|
||||||
click() {
|
|
||||||
config.set('options.likeButtons', 'force');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hide',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.likeButtons') === 'hide',
|
|
||||||
click() {
|
|
||||||
config.set('options.likeButtons', 'hide');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Theme',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'No theme',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
|
|
||||||
click() {
|
|
||||||
config.set('options.themes', []);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Import custom CSS file',
|
|
||||||
type: 'normal',
|
|
||||||
async click() {
|
|
||||||
const { filePaths } = await dialog.showOpenDialog({
|
|
||||||
filters: [{ name: 'CSS Files', extensions: ['css'] }],
|
|
||||||
properties: ['openFile', 'multiSelections'],
|
|
||||||
});
|
|
||||||
if (filePaths) {
|
|
||||||
config.set('options.themes', filePaths);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Single instance lock',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: true,
|
|
||||||
click(item) {
|
|
||||||
if (!item.checked && app.hasSingleInstanceLock()) {
|
|
||||||
app.releaseSingleInstanceLock();
|
|
||||||
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
|
||||||
app.requestSingleInstanceLock();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Always on top',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.alwaysOnTop'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.alwaysOnTop', item.checked);
|
|
||||||
win.setAlwaysOnTop(item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(is.windows() || is.linux()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: 'Hide menu',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.hideMenu'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.hideMenu', item.checked);
|
|
||||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
|
||||||
dialog.showMessageBox(win, {
|
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
|
||||||
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
|
||||||
...(is.windows() || is.macOS()
|
|
||||||
? // Only works on Win/Mac
|
|
||||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: 'Start at login',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.startAtLogin'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.startAtLogin', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
|
||||||
{
|
|
||||||
label: 'Tray',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Disabled',
|
|
||||||
type: 'radio',
|
|
||||||
checked: !config.get('options.tray'),
|
|
||||||
click() {
|
|
||||||
config.setMenuOption('options.tray', false);
|
|
||||||
config.setMenuOption('options.appVisible', true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Enabled + app visible',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.tray') && config.get('options.appVisible'),
|
|
||||||
click() {
|
|
||||||
config.setMenuOption('options.tray', true);
|
|
||||||
config.setMenuOption('options.appVisible', true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Enabled + app hidden',
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('options.tray') && !config.get('options.appVisible'),
|
|
||||||
click() {
|
|
||||||
config.setMenuOption('options.tray', true);
|
|
||||||
config.setMenuOption('options.appVisible', false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Play/Pause on click',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.trayClickPlayPause'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.trayClickPlayPause', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Advanced options',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Set Proxy',
|
|
||||||
type: 'normal',
|
|
||||||
async click(item) {
|
|
||||||
await setProxy(item, win);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Override useragent',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.overrideUserAgent'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.overrideUserAgent', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Disable hardware acceleration',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.disableHardwareAcceleration'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Restart on config changes',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.restartOnConfigChanges'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.restartOnConfigChanges', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reset App cache when app starts',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('options.autoResetAppCache'),
|
|
||||||
click(item) {
|
|
||||||
config.setMenuOption('options.autoResetAppCache', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
is.macOS()
|
|
||||||
? {
|
|
||||||
label: 'Toggle DevTools',
|
|
||||||
// Cannot use "toggleDevTools" role in macOS
|
|
||||||
click() {
|
|
||||||
const { webContents } = win;
|
|
||||||
if (webContents.isDevToolsOpened()) {
|
|
||||||
webContents.closeDevTools();
|
|
||||||
} else {
|
|
||||||
webContents.openDevTools();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { role: 'toggleDevTools' },
|
|
||||||
{
|
|
||||||
label: 'Edit config.json',
|
|
||||||
click() {
|
|
||||||
config.edit();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'View',
|
|
||||||
submenu: [
|
|
||||||
{ role: 'reload' },
|
|
||||||
{ role: 'forceReload' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
|
|
||||||
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
|
|
||||||
{ role: 'resetZoom' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'togglefullscreen' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Navigation',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Go back',
|
|
||||||
click() {
|
|
||||||
if (win.webContents.canGoBack()) {
|
|
||||||
win.webContents.goBack();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Go forward',
|
|
||||||
click() {
|
|
||||||
if (win.webContents.canGoForward()) {
|
|
||||||
win.webContents.goForward();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Copy current URL',
|
|
||||||
click() {
|
|
||||||
const currentURL = win.webContents.getURL();
|
|
||||||
clipboard.writeText(currentURL);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Restart App',
|
|
||||||
click: restart,
|
|
||||||
},
|
|
||||||
{ role: 'quit' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
|
||||||
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
const { name } = app;
|
|
||||||
menuTemplate.unshift({
|
|
||||||
label: name,
|
|
||||||
submenu: [
|
|
||||||
{ role: 'about' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'hide' },
|
|
||||||
{ role: 'hideOthers' },
|
|
||||||
{ role: 'unhide' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'selectAll' },
|
|
||||||
{ role: 'cut' },
|
|
||||||
{ role: 'copy' },
|
|
||||||
{ role: 'paste' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'minimize' },
|
|
||||||
{ role: 'close' },
|
|
||||||
{ role: 'quit' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
|
||||||
Menu.setApplicationMenu(menu);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Set Proxy',
|
|
||||||
label: 'Enter Proxy Address: (leave empty to disable)',
|
|
||||||
value: config.get('options.proxy'),
|
|
||||||
type: 'input',
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'url',
|
|
||||||
placeholder: "Example: 'socks5://127.0.0.1:9999",
|
|
||||||
},
|
|
||||||
width: 450,
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (typeof output === 'string') {
|
|
||||||
config.setMenuOption('options.proxy', output);
|
|
||||||
item.checked = output !== '';
|
|
||||||
} else { // User pressed cancel
|
|
||||||
item.checked = !item.checked; // Reset checkbox
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9671
package-lock.json
generated
9671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
185
package.json
185
package.json
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "youtube-music",
|
"name": "youtube-music",
|
||||||
"productName": "YouTube Music",
|
"productName": "YouTube Music",
|
||||||
"version": "2.0.2",
|
"version": "3.2.0",
|
||||||
"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,31 +17,18 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"!*",
|
"!*",
|
||||||
"dist",
|
"dist",
|
||||||
|
"assets",
|
||||||
"license",
|
"license",
|
||||||
"!node_modules",
|
"!node_modules",
|
||||||
"node_modules/custom-electron-prompt/**",
|
"node_modules/custom-electron-prompt/**",
|
||||||
"node_modules/youtubei.js/**",
|
|
||||||
"node_modules/undici/**",
|
|
||||||
"node_modules/@fastify/busboy/**",
|
|
||||||
"node_modules/jintr/**",
|
|
||||||
"node_modules/acorn/**",
|
|
||||||
"node_modules/tslib/**",
|
|
||||||
"node_modules/semver/**",
|
|
||||||
"node_modules/lru-cache/**",
|
|
||||||
"node_modules/detect-libc/**",
|
|
||||||
"node_modules/color/**",
|
|
||||||
"node_modules/color-convert/**",
|
|
||||||
"node_modules/color-string/**",
|
|
||||||
"node_modules/color-name/**",
|
|
||||||
"node_modules/simple-swizzle/**",
|
|
||||||
"node_modules/is-arrayish/**",
|
|
||||||
"node_modules/@cliqz/adblocker-electron-preload/**",
|
"node_modules/@cliqz/adblocker-electron-preload/**",
|
||||||
"node_modules/@cliqz/adblocker-content/**",
|
|
||||||
"node_modules/@cliqz/adblocker-extended-selectors/**",
|
|
||||||
"node_modules/@ffmpeg.wasm/core-mt/**",
|
"node_modules/@ffmpeg.wasm/core-mt/**",
|
||||||
"!node_modules/**/*.map",
|
"!node_modules/**/*.map",
|
||||||
"!node_modules/**/*.ts"
|
"!node_modules/**/*.ts"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"assets"
|
||||||
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"identity": null,
|
"identity": null,
|
||||||
"target": [
|
"target": [
|
||||||
@ -90,6 +77,11 @@
|
|||||||
"rpm"
|
"rpm"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"rpm": {
|
||||||
|
"depends": [
|
||||||
|
"/usr/lib64/libuuid.so.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"snap": {
|
"snap": {
|
||||||
"slots": [
|
"slots": [
|
||||||
{
|
{
|
||||||
@ -104,109 +96,126 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run build && playwright test",
|
"test": "playwright test",
|
||||||
"test:debug": "DEBUG=pw:browser* npm run build && 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": "npm run rollup:preload && npm run rollup:main",
|
"start": "electron-vite preview",
|
||||||
"start": "npm run build && electron ./dist/index.js",
|
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
||||||
"start:debug": "ELECTRON_ENABLE_LOGGING=1 npm run start",
|
"dev": "electron-vite dev --watch",
|
||||||
"generate:package": "node utils/generate-package-json.js",
|
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
||||||
"postinstall": "npm run plugins && npm run clean",
|
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||||
"clean": "del-cli dist && del-cli pack",
|
"dist": "pnpm clean && pnpm build && pnpm electron-builder --win --mac --linux -p never",
|
||||||
"dist": "npm run clean && npm run build && electron-builder --win --mac --linux -p never",
|
"dist:linux": "pnpm clean && pnpm build && pnpm electron-builder --linux -p never",
|
||||||
"dist:linux": "npm run clean && npm run build && electron-builder --linux -p never",
|
"dist:mac": "pnpm clean && pnpm build && pnpm electron-builder --mac dmg:x64 -p never",
|
||||||
"dist:mac": "npm run clean && npm run build && electron-builder --mac dmg:x64 -p never",
|
"dist:mac:arm64": "pnpm clean && pnpm build && pnpm electron-builder --mac dmg:arm64 -p never",
|
||||||
"dist:mac:arm64": "npm run clean && npm run build && electron-builder --mac dmg:arm64 -p never",
|
"dist:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p never",
|
||||||
"dist:win": "npm run clean && npm run build && electron-builder --win -p never",
|
"dist:win:x64": "pnpm clean && pnpm build && pnpm electron-builder --win nsis-web:x64 -p never",
|
||||||
"dist:win:x64": "npm run clean && npm run build && electron-builder --win nsis-web:x64 -p never",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"changelog": "auto-changelog",
|
"changelog": "npx --yes auto-changelog",
|
||||||
"plugins": "npm run plugin:bypass-age-restrictions",
|
"release:linux": "pnpm clean && pnpm build && pnpm electron-builder --linux -p always -c.snap.publish=github",
|
||||||
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && npm run generate:package simple-youtube-age-restriction-bypass",
|
"release:mac": "pnpm clean && pnpm build && pnpm electron-builder --mac -p always",
|
||||||
"release:linux": "npm run clean && npm run build && electron-builder --linux -p always -c.snap.publish=github",
|
"release:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p always",
|
||||||
"release:mac": "npm run clean && npm run build && electron-builder --mac -p always",
|
|
||||||
"release:win": "npm run clean && npm run build && electron-builder --win -p always",
|
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "0.18.20",
|
||||||
|
"usocket": "1.0.1",
|
||||||
|
"rollup": "4.9.2",
|
||||||
|
"node-gyp": "10.0.1",
|
||||||
|
"xml2js": "0.6.2",
|
||||||
|
"node-fetch": "3.3.2",
|
||||||
|
"@electron/universal": "2.0.1",
|
||||||
|
"@babel/runtime": "7.23.7"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cliqz/adblocker-electron": "1.26.7",
|
"@cliqz/adblocker-electron": "1.26.12",
|
||||||
|
"@cliqz/adblocker-electron-preload": "1.26.12",
|
||||||
|
"@electron-toolkit/tsconfig": "1.0.1",
|
||||||
|
"@electron/remote": "2.1.1",
|
||||||
"@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.5",
|
||||||
"@xhayper/discord-rpc": "1.0.23",
|
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||||
|
"@jellybrick/mpris-service": "2.1.4",
|
||||||
|
"@xhayper/discord-rpc": "1.1.1",
|
||||||
"async-mutex": "0.4.0",
|
"async-mutex": "0.4.0",
|
||||||
"butterchurn": "2.6.7",
|
"butterchurn": "3.0.0-beta.4",
|
||||||
"butterchurn-presets": "2.4.7",
|
"butterchurn-presets": "3.0.0-beta.4",
|
||||||
|
"color": "4.2.3",
|
||||||
"conf": "10.2.0",
|
"conf": "10.2.0",
|
||||||
"custom-electron-prompt": "1.5.7",
|
"custom-electron-prompt": "1.5.7",
|
||||||
"electron-better-web-request": "1.0.1",
|
"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.13",
|
||||||
"keyboardevent-from-electron-accelerator": "2.0.0",
|
"keyboardevent-from-electron-accelerator": "2.0.0",
|
||||||
"keyboardevents-areequal": "0.2.2",
|
"keyboardevents-areequal": "0.2.2",
|
||||||
"mpris-service": "2.1.2",
|
"node-html-parser": "6.1.12",
|
||||||
"node-id3": "0.2.6",
|
"node-id3": "0.2.6",
|
||||||
"simple-youtube-age-restriction-bypass": "git+https://github.com/MiepHD/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.5",
|
"peerjs": "1.5.2",
|
||||||
|
"semver": "7.5.4",
|
||||||
|
"serve": "14.2.1",
|
||||||
|
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
|
||||||
|
"ts-morph": "21.0.1",
|
||||||
"vudio": "2.1.1",
|
"vudio": "2.1.1",
|
||||||
"x11": "2.3.0",
|
"x11": "2.3.0",
|
||||||
"youtubei.js": "6.4.1",
|
"youtubei.js": "8.1.0"
|
||||||
"ytpl": "2.3.0"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"rollup": "4.0.2",
|
|
||||||
"node-gyp": "9.4.0",
|
|
||||||
"xml2js": "0.6.2",
|
|
||||||
"dbus-next": "0.10.2",
|
|
||||||
"node-fetch": "2.7.0",
|
|
||||||
"@electron/universal": "1.4.2",
|
|
||||||
"electron": "27.0.0-beta.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.38.1",
|
"@playwright/test": "1.41.0-alpha-dec-18-2023",
|
||||||
"@rollup/plugin-commonjs": "25.0.5",
|
|
||||||
"@rollup/plugin-image": "3.0.3",
|
|
||||||
"@rollup/plugin-json": "6.0.1",
|
|
||||||
"@rollup/plugin-node-resolve": "15.2.2",
|
|
||||||
"@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.1",
|
"@types/color": "3.0.6",
|
||||||
"@types/howler": "2.2.9",
|
"@types/electron-localshortcut": "3.1.3",
|
||||||
"@types/html-to-text": "9.0.2",
|
"@types/howler": "2.2.11",
|
||||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
"@types/html-to-text": "9.0.4",
|
||||||
"auto-changelog": "2.4.0",
|
"@types/semver": "7.5.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "6.16.0",
|
||||||
|
"bufferutil": "4.0.8",
|
||||||
|
"builtin-modules": "3.3.0",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
"del-cli": "5.1.0",
|
"del-cli": "5.1.0",
|
||||||
"electron": "27.0.0-beta.9",
|
"electron": "28.1.0",
|
||||||
"electron-builder": "24.6.4",
|
"electron-builder": "24.9.1",
|
||||||
"electron-devtools-installer": "3.2.0",
|
"electron-devtools-installer": "3.2.0",
|
||||||
"eslint": "8.51.0",
|
"electron-vite": "2.0.0-beta.2",
|
||||||
"eslint-plugin-import": "2.28.1",
|
"esbuild": "0.18.20",
|
||||||
"eslint-plugin-prettier": "5.0.0",
|
"eslint": "8.56.0",
|
||||||
"node-gyp": "9.4.0",
|
"eslint-import-resolver-exports": "1.0.0-beta.5",
|
||||||
"playwright": "1.38.1",
|
"eslint-import-resolver-typescript": "3.6.1",
|
||||||
"rollup": "4.0.2",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"rollup-plugin-copy": "3.5.0",
|
"eslint-plugin-prettier": "5.1.2",
|
||||||
"rollup-plugin-import-css": "3.3.4",
|
"glob": "10.3.10",
|
||||||
"rollup-plugin-string": "3.0.0",
|
"node-gyp": "10.0.1",
|
||||||
"typescript": "5.2.2"
|
"playwright": "1.41.0-alpha-dec-18-2023",
|
||||||
|
"rollup": "4.9.2",
|
||||||
|
"typescript": "5.3.3",
|
||||||
|
"utf-8-validate": "6.0.3",
|
||||||
|
"vite": "5.0.10",
|
||||||
|
"vite-plugin-inspect": "0.8.1",
|
||||||
|
"vite-plugin-resolve": "2.5.1",
|
||||||
|
"ws": "8.16.0"
|
||||||
},
|
},
|
||||||
"auto-changelog": {
|
"auto-changelog": {
|
||||||
"hideCredit": true,
|
"hideCredit": true,
|
||||||
"package": true,
|
"package": true,
|
||||||
"unreleased": true,
|
"unreleased": true,
|
||||||
"output": "changelog.md"
|
"output": "changelog.md"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@8.13.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (await shouldUseBlocklists()) {
|
|
||||||
loadAdBlockerEngine(
|
|
||||||
win.webContents.session,
|
|
||||||
options.cache,
|
|
||||||
options.additionalBlockLists,
|
|
||||||
options.disableDefaultLists,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
// Used for caching
|
|
||||||
import path from 'node:path';
|
|
||||||
import fs, { promises } from 'node:fs';
|
|
||||||
|
|
||||||
import { ElectronBlocker } from '@cliqz/adblocker-electron';
|
|
||||||
import { app } from 'electron';
|
|
||||||
|
|
||||||
const SOURCES = [
|
|
||||||
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
|
|
||||||
// UBlock Origin
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt',
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2020.txt',
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2021.txt',
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2022.txt',
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2023.txt',
|
|
||||||
// Fanboy Annoyances
|
|
||||||
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const loadAdBlockerEngine = (
|
|
||||||
session: Electron.Session | undefined = undefined,
|
|
||||||
cache = true,
|
|
||||||
additionalBlockLists = [],
|
|
||||||
disableDefaultLists: boolean | unknown[] = false,
|
|
||||||
) => {
|
|
||||||
// Only use cache if no additional blocklists are passed
|
|
||||||
let cacheDirectory: string;
|
|
||||||
if (app.isPackaged) {
|
|
||||||
cacheDirectory = path.join(app.getPath('userData'), 'adblock_cache');
|
|
||||||
} else {
|
|
||||||
cacheDirectory = path.resolve(__dirname, 'adblock_cache');
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(cacheDirectory)) {
|
|
||||||
fs.mkdirSync(cacheDirectory);
|
|
||||||
}
|
|
||||||
const cachingOptions
|
|
||||||
= cache && additionalBlockLists.length === 0
|
|
||||||
? {
|
|
||||||
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
|
|
||||||
read: promises.readFile,
|
|
||||||
write: promises.writeFile,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const lists = [
|
|
||||||
...(
|
|
||||||
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
|
|
||||||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
|
|
||||||
),
|
|
||||||
...additionalBlockLists,
|
|
||||||
];
|
|
||||||
|
|
||||||
ElectronBlocker.fromLists(
|
|
||||||
fetch,
|
|
||||||
lists,
|
|
||||||
{
|
|
||||||
// When generating the engine for caching, do not load network filters
|
|
||||||
// So that enhancing the session works as expected
|
|
||||||
// Allowing to define multiple webRequest listeners
|
|
||||||
loadNetworkFilters: session !== undefined,
|
|
||||||
},
|
|
||||||
cachingOptions,
|
|
||||||
)
|
|
||||||
.then((blocker) => {
|
|
||||||
if (session) {
|
|
||||||
blocker.enableBlockingInSession(session);
|
|
||||||
} else {
|
|
||||||
console.log('Successfully generated adBlocker engine.');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => console.log('Error loading adBlocker engine', error));
|
|
||||||
};
|
|
||||||
|
|
||||||
export default { loadAdBlockerEngine };
|
|
||||||
@ -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 = async () => await config.get('blocker') !== blockers.InPlayer;
|
|
||||||
|
|
||||||
export default Object.assign(config, {
|
|
||||||
shouldUseBlocklists,
|
|
||||||
blockers,
|
|
||||||
});
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export default () => {
|
|
||||||
const path = '@cliqz/adblocker-electron-preload'; // prevent require hoisting
|
|
||||||
require(path);
|
|
||||||
};
|
|
||||||
3
plugins/adblocker/inject.d.ts
vendored
3
plugins/adblocker/inject.d.ts
vendored
@ -1,3 +0,0 @@
|
|||||||
const inject: () => void;
|
|
||||||
|
|
||||||
export default inject;
|
|
||||||
@ -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);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
import inject from './inject';
|
|
||||||
import injectCliqzPreload from './inject-cliqz-preload';
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
if (await config.shouldUseBlocklists()) {
|
|
||||||
// Preload adblocker to inject scripts/styles
|
|
||||||
injectCliqzPreload();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
||||||
} else if ((await config.get('blocker')) === blockers.InPlayer) {
|
|
||||||
inject();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
yt-page-navigation-progress {
|
|
||||||
--yt-page-navigation-container-color: #00000046 !important;
|
|
||||||
--yt-page-navigation-progress-color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-page {
|
|
||||||
transition: transform 300ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav-bar-background {
|
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mini-guide-background {
|
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
|
||||||
border-right: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guide-wrapper {
|
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#img, #player, .song-media-controls.style-scope.ytmusic-player {
|
|
||||||
border-radius: 2% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#items {
|
|
||||||
border-radius: 10px !important;
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (_: ConfigType<'ambient-mode'>) => {
|
|
||||||
const interpolationTime = 3000; // interpolation time (ms)
|
|
||||||
const framerate = 30; // frame
|
|
||||||
const qualityRatio = 50; // width size (pixel)
|
|
||||||
|
|
||||||
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 / framerate) * (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}px`;
|
|
||||||
blurCanvas.style.height = `${newHeight}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 / framerate)));
|
|
||||||
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 / framerate)));
|
|
||||||
};
|
|
||||||
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);
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
#song-video canvas.html5-blur-canvas{
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
filter: blur(100px);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export default () => {
|
|
||||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
|
||||||
require('simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js');
|
|
||||||
};
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron';
|
|
||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
|
||||||
{
|
|
||||||
title: 'Choose Caption',
|
|
||||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
|
||||||
type: 'select',
|
|
||||||
value: currentIndex,
|
|
||||||
selectOptions: captionLabels,
|
|
||||||
resizable: true,
|
|
||||||
...promptOptions(),
|
|
||||||
},
|
|
||||||
win,
|
|
||||||
));
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<tp-yt-paper-icon-button aria-disabled="false" aria-label="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">
|
|
||||||
<svg class="style-scope yt-icon"
|
|
||||||
focusable="false" preserveAspectRatio="xMidYMid meet"
|
|
||||||
style="pointer-events: none; display: block; width: 100%; height: 100%;"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<g class="style-scope yt-icon">
|
|
||||||
<path
|
|
||||||
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>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</tp-yt-iron-icon>
|
|
||||||
</tp-yt-paper-icon-button>
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
};
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
import { app, dialog } 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 activityInfo: SetActivity = {
|
|
||||||
details: songInfo.title,
|
|
||||||
state: songInfo.artist,
|
|
||||||
largeImageKey: songInfo.imageSrc ?? '',
|
|
||||||
largeImageText: songInfo.album ?? '',
|
|
||||||
buttons: [
|
|
||||||
...(options.listenAlong ? [{ label: 'Listen Along', url: songInfo.url ?? '' }] : []),
|
|
||||||
{ 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', () => {
|
|
||||||
registerCallback(updateActivity);
|
|
||||||
connect();
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { clear, connect, isConnected, registerRefresh } from './back';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
import { singleton } from '../../providers/decorators';
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
|
||||||
registerRefresh(refreshMenu);
|
|
||||||
});
|
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
|
||||||
|
|
||||||
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
|
|
||||||
registerRefreshOnce(refreshMenu);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: isConnected() ? 'Connected' : 'Reconnect',
|
|
||||||
enabled: !isConnected(),
|
|
||||||
click: () => connect(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Auto reconnect',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.autoReconnect,
|
|
||||||
click(item: Electron.MenuItem) {
|
|
||||||
options.autoReconnect = item.checked;
|
|
||||||
setMenuOptions('discord', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clear activity',
|
|
||||||
click: clear,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clear activity after timeout',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.activityTimoutEnabled,
|
|
||||||
click(item: Electron.MenuItem) {
|
|
||||||
options.activityTimoutEnabled = item.checked;
|
|
||||||
setMenuOptions('discord', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Listen Along',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.listenAlong,
|
|
||||||
click(item: Electron.MenuItem) {
|
|
||||||
options.listenAlong = item.checked;
|
|
||||||
setMenuOptions('discord', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hide duration left',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.hideDurationLeft,
|
|
||||||
click(item: Electron.MenuItem) {
|
|
||||||
options.hideDurationLeft = item.checked;
|
|
||||||
setMenuOptions('discord', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Set inactivity timeout',
|
|
||||||
click: () => setInactivityTimeout(win, options),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Set Inactivity Timeout',
|
|
||||||
label: 'Enter inactivity timeout in seconds:',
|
|
||||||
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
|
|
||||||
type: 'counter',
|
|
||||||
counterOptions: { minimum: 0, multiFire: true },
|
|
||||||
width: 450,
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
|
||||||
options.activityTimoutTime = Math.round(~~output * 1e3);
|
|
||||||
setMenuOptions('discord', options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,587 +0,0 @@
|
|||||||
import { createWriteStream, existsSync, mkdirSync, writeFileSync, } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
|
||||||
import { ClientType, Innertube, UniversalCache, Utils } from 'youtubei.js';
|
|
||||||
import is from 'electron-is';
|
|
||||||
import ytpl from 'ytpl';
|
|
||||||
// REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
|
|
||||||
import filenamify from 'filenamify';
|
|
||||||
import { Mutex } from 'async-mutex';
|
|
||||||
import { createFFmpeg } from '@ffmpeg.wasm/main';
|
|
||||||
|
|
||||||
import NodeID3, { TagConstants } from 'node-id3';
|
|
||||||
|
|
||||||
import PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
|
||||||
import { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
|
||||||
|
|
||||||
import TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
|
||||||
|
|
||||||
import { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
|
||||||
|
|
||||||
import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { fetchFromGenius } from '../lyrics-genius/back';
|
|
||||||
import { isEnabled } from '../../config/plugins';
|
|
||||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
import { cache } from '../../providers/decorators';
|
|
||||||
|
|
||||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
|
||||||
|
|
||||||
|
|
||||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
|
||||||
|
|
||||||
const ffmpeg = createFFmpeg({
|
|
||||||
log: false,
|
|
||||||
logger() {
|
|
||||||
}, // Console.log,
|
|
||||||
progress() {
|
|
||||||
}, // Console.log,
|
|
||||||
});
|
|
||||||
const ffmpegMutex = new Mutex();
|
|
||||||
|
|
||||||
let yt: Innertube;
|
|
||||||
let win: BrowserWindow;
|
|
||||||
let playingUrl: string;
|
|
||||||
|
|
||||||
const sendError = (error: Error, source?: string) => {
|
|
||||||
win.setProgressBar(-1); // Close progress bar
|
|
||||||
setBadge(0); // Close badge
|
|
||||||
sendFeedback_(win); // Reset feedback
|
|
||||||
|
|
||||||
const songNameMessage = source ? `\nin ${source}` : '';
|
|
||||||
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
|
|
||||||
const message = `${error.toString()}${songNameMessage}${cause}`;
|
|
||||||
|
|
||||||
console.error(message, error, error?.stack);
|
|
||||||
dialog.showMessageBox({
|
|
||||||
type: 'info',
|
|
||||||
buttons: ['OK'],
|
|
||||||
title: 'Error in download!',
|
|
||||||
message: 'Argh! Apologies, download failed…',
|
|
||||||
detail: message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (win_: BrowserWindow) => {
|
|
||||||
win = win_;
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
|
|
||||||
const cookie = (await win.webContents.session.cookies.get({ url: 'https://music.youtube.com' })).map((it) =>
|
|
||||||
it.name + '=' + it.value + ';'
|
|
||||||
).join('');
|
|
||||||
yt = await Innertube.create({
|
|
||||||
cache: new UniversalCache(false),
|
|
||||||
cookie,
|
|
||||||
generate_session_locally: true,
|
|
||||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
|
||||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
|
||||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
|
||||||
});
|
|
||||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
|
||||||
downloadPlaylist(url),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function downloadSong(
|
|
||||||
url: string,
|
|
||||||
playlistFolder: string | undefined = undefined,
|
|
||||||
trackId: string | undefined = undefined,
|
|
||||||
increasePlaylistProgress: (value: number) => void = () => {
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let resolvedName;
|
|
||||||
try {
|
|
||||||
await downloadSongUnsafe(
|
|
||||||
url,
|
|
||||||
(name: string) => resolvedName = name,
|
|
||||||
playlistFolder,
|
|
||||||
trackId,
|
|
||||||
increasePlaylistProgress,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(error as Error, resolvedName || url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadSongUnsafe(
|
|
||||||
url: string,
|
|
||||||
setName: (name: string) => void,
|
|
||||||
playlistFolder: string | undefined = undefined,
|
|
||||||
trackId: string | undefined = undefined,
|
|
||||||
increasePlaylistProgress: (value: number) => void = () => {},
|
|
||||||
) {
|
|
||||||
const sendFeedback = (message: unknown, progress?: number) => {
|
|
||||||
if (!playlistFolder) {
|
|
||||||
sendFeedback_(win, message);
|
|
||||||
if (progress && !isNaN(progress)) {
|
|
||||||
win.setProgressBar(progress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendFeedback('Downloading...', 2);
|
|
||||||
|
|
||||||
const id = getVideoId(url);
|
|
||||||
if (typeof id !== 'string') throw new Error('Video not found');
|
|
||||||
|
|
||||||
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
throw new Error('Video not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = getMetadata(info);
|
|
||||||
if (metadata.album === 'N/A') {
|
|
||||||
metadata.album = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata.trackId = trackId;
|
|
||||||
|
|
||||||
const dir
|
|
||||||
= playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
|
||||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
|
||||||
metadata.title
|
|
||||||
}`;
|
|
||||||
setName(name);
|
|
||||||
|
|
||||||
let playabilityStatus = info.playability_status;
|
|
||||||
let bypassedResult = null;
|
|
||||||
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
|
|
||||||
// Try to bypass the age restriction
|
|
||||||
bypassedResult = await getAndroidTvInfo(id);
|
|
||||||
playabilityStatus = bypassedResult.playability_status;
|
|
||||||
|
|
||||||
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
|
|
||||||
throw new Error(
|
|
||||||
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
info = bypassedResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playabilityStatus.status === 'UNPLAYABLE') {
|
|
||||||
const errorScreen = playabilityStatus.error_screen as PlayerErrorMessage | null;
|
|
||||||
throw new Error(
|
|
||||||
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = config.get('preset') ?? 'mp3';
|
|
||||||
let presetSetting: { extension: string; ffmpegArgs: string[] } | null = null;
|
|
||||||
if (preset === 'opus') {
|
|
||||||
presetSetting = presets[preset];
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = filenamify(`${name}.${presetSetting?.extension ?? 'mp3'}`, {
|
|
||||||
replacement: '_',
|
|
||||||
maxLength: 255,
|
|
||||||
});
|
|
||||||
const filePath = join(dir, filename);
|
|
||||||
|
|
||||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
|
||||||
sendFeedback(null, -1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadOptions: FormatOptions = {
|
|
||||||
type: 'audio', // Audio, video or video+audio
|
|
||||||
quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
|
||||||
format: 'any', // Media container format
|
|
||||||
};
|
|
||||||
|
|
||||||
const format = info.chooseFormat(downloadOptions);
|
|
||||||
const stream = await info.download(downloadOptions);
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iterableStream = Utils.streamToIterable(stream);
|
|
||||||
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ffmpegArgs = config.get('ffmpegArgs');
|
|
||||||
|
|
||||||
if (presetSetting && presetSetting?.extension !== 'mp3') {
|
|
||||||
const file = createWriteStream(filePath);
|
|
||||||
let downloaded = 0;
|
|
||||||
const total: number = format.content_length ?? 1;
|
|
||||||
|
|
||||||
for await (const chunk of iterableStream) {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
const ratio = downloaded / total;
|
|
||||||
const progress = Math.floor(ratio * 100);
|
|
||||||
sendFeedback(`Download: ${progress}%`, ratio);
|
|
||||||
increasePlaylistProgress(ratio);
|
|
||||||
file.write(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ffmpegWriteTags(
|
|
||||||
filePath,
|
|
||||||
metadata,
|
|
||||||
presetSetting.ffmpegArgs,
|
|
||||||
ffmpegArgs,
|
|
||||||
);
|
|
||||||
sendFeedback(null, -1);
|
|
||||||
} else {
|
|
||||||
const fileBuffer = await iterableStreamToMP3(
|
|
||||||
iterableStream,
|
|
||||||
metadata,
|
|
||||||
ffmpegArgs,
|
|
||||||
format.content_length ?? 0,
|
|
||||||
sendFeedback,
|
|
||||||
increasePlaylistProgress,
|
|
||||||
);
|
|
||||||
if (fileBuffer) {
|
|
||||||
const buffer = await writeID3(Buffer.from(fileBuffer), metadata, sendFeedback);
|
|
||||||
if (buffer) {
|
|
||||||
writeFileSync(filePath, buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendFeedback(null, -1);
|
|
||||||
console.info(`Done: "${filePath}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function iterableStreamToMP3(
|
|
||||||
stream: AsyncGenerator<Uint8Array, void>,
|
|
||||||
metadata: CustomSongInfo,
|
|
||||||
ffmpegArgs: string[],
|
|
||||||
contentLength: number,
|
|
||||||
sendFeedback: (str: string, value?: number) => void,
|
|
||||||
increasePlaylistProgress: (value: number) => void = () => {
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const chunks = [];
|
|
||||||
let downloaded = 0;
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
chunks.push(chunk);
|
|
||||||
const ratio = downloaded / contentLength;
|
|
||||||
const progress = Math.floor(ratio * 100);
|
|
||||||
sendFeedback(`Download: ${progress}%`, ratio);
|
|
||||||
// 15% for download, 85% for conversion
|
|
||||||
// This is a very rough estimate, trying to make the progress bar look nice
|
|
||||||
increasePlaylistProgress(ratio * 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendFeedback('Loading…', 2); // Indefinite progress bar after download
|
|
||||||
|
|
||||||
const buffer = Buffer.concat(chunks);
|
|
||||||
const safeVideoName = randomBytes(32).toString('hex');
|
|
||||||
const releaseFFmpegMutex = await ffmpegMutex.acquire();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!ffmpeg.isLoaded()) {
|
|
||||||
await ffmpeg.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
sendFeedback('Preparing file…');
|
|
||||||
ffmpeg.FS('writeFile', safeVideoName, buffer);
|
|
||||||
|
|
||||||
sendFeedback('Converting…');
|
|
||||||
|
|
||||||
ffmpeg.setProgress(({ ratio }) => {
|
|
||||||
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
|
|
||||||
increasePlaylistProgress(0.15 + (ratio * 0.85));
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ffmpeg.run(
|
|
||||||
'-i',
|
|
||||||
safeVideoName,
|
|
||||||
...ffmpegArgs,
|
|
||||||
...getFFmpegMetadataArgs(metadata),
|
|
||||||
`${safeVideoName}.mp3`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
ffmpeg.FS('unlink', safeVideoName);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendFeedback('Saving…');
|
|
||||||
|
|
||||||
try {
|
|
||||||
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
|
|
||||||
} finally {
|
|
||||||
ffmpeg.FS('unlink', `${safeVideoName}.mp3`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(error as Error, safeVideoName);
|
|
||||||
} finally {
|
|
||||||
releaseFFmpegMutex();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCoverBuffer = cache(async (url: string) => {
|
|
||||||
const nativeImage = cropMaxWidth(await getImage(url));
|
|
||||||
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function writeID3(buffer: Buffer, metadata: CustomSongInfo, sendFeedback: (str: string, value?: number) => void) {
|
|
||||||
try {
|
|
||||||
sendFeedback('Writing ID3 tags...');
|
|
||||||
const tags: NodeID3.Tags = {};
|
|
||||||
|
|
||||||
// Create the metadata tags
|
|
||||||
tags.title = metadata.title;
|
|
||||||
tags.artist = metadata.artist;
|
|
||||||
|
|
||||||
if (metadata.album) {
|
|
||||||
tags.album = metadata.album;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
|
|
||||||
if (coverBuffer) {
|
|
||||||
tags.image = {
|
|
||||||
mime: 'image/png',
|
|
||||||
type: {
|
|
||||||
id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
|
|
||||||
},
|
|
||||||
description: 'thumbnail',
|
|
||||||
imageBuffer: coverBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEnabled('lyrics-genius')) {
|
|
||||||
const lyrics = await fetchFromGenius(metadata);
|
|
||||||
if (lyrics) {
|
|
||||||
tags.unsynchronisedLyrics = {
|
|
||||||
language: '',
|
|
||||||
text: lyrics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.trackId) {
|
|
||||||
tags.trackNumber = metadata.trackId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeID3.write(tags, buffer);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadPlaylist(givenUrl?: string | URL) {
|
|
||||||
try {
|
|
||||||
givenUrl = new URL(givenUrl ?? '');
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistId
|
|
||||||
= getPlaylistID(givenUrl)
|
|
||||||
|| getPlaylistID(new URL(win.webContents.getURL()))
|
|
||||||
|| getPlaylistID(new URL(playingUrl));
|
|
||||||
|
|
||||||
if (!playlistId) {
|
|
||||||
sendError(new Error('No playlist ID found'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
|
|
||||||
|
|
||||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
|
||||||
sendFeedback('Getting playlist info…');
|
|
||||||
let playlist: ytpl.Result;
|
|
||||||
try {
|
|
||||||
playlist = await ytpl(playlistId, {
|
|
||||||
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(
|
|
||||||
Error(`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(error)}`),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlist.items.length === 0) {
|
|
||||||
sendError(new Error('Playlist is empty'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlist.items.length === 1) {
|
|
||||||
sendFeedback('Playlist has only one item, downloading it directly');
|
|
||||||
await downloadSong(playlist.items[0].url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAlbum = playlist.title.startsWith('Album - ');
|
|
||||||
if (isAlbum) {
|
|
||||||
playlist.title = playlist.title.slice(8);
|
|
||||||
}
|
|
||||||
|
|
||||||
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
|
|
||||||
|
|
||||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
|
||||||
const playlistFolder = join(folder, safePlaylistTitle);
|
|
||||||
if (existsSync(playlistFolder)) {
|
|
||||||
if (!config.get('skipExisting')) {
|
|
||||||
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mkdirSync(playlistFolder, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.showMessageBox({
|
|
||||||
type: 'info',
|
|
||||||
buttons: ['OK'],
|
|
||||||
title: 'Started Download',
|
|
||||||
message: `Downloading Playlist "${playlist.title}"`,
|
|
||||||
detail: `(${playlist.items.length} songs)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log(
|
|
||||||
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
win.setProgressBar(2); // Starts with indefinite bar
|
|
||||||
|
|
||||||
setBadge(playlist.items.length);
|
|
||||||
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
const progressStep = 1 / playlist.items.length;
|
|
||||||
|
|
||||||
const increaseProgress = (itemPercentage: number) => {
|
|
||||||
const currentProgress = (counter - 1) / (playlist.items.length ?? 1);
|
|
||||||
const newProgress = currentProgress + (progressStep * itemPercentage);
|
|
||||||
win.setProgressBar(newProgress);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const song of playlist.items) {
|
|
||||||
sendFeedback(`Downloading ${counter}/${playlist.items.length}...`);
|
|
||||||
const trackId = isAlbum ? counter : undefined;
|
|
||||||
await downloadSong(
|
|
||||||
song.url,
|
|
||||||
playlistFolder,
|
|
||||||
trackId?.toString(),
|
|
||||||
increaseProgress,
|
|
||||||
).catch((error) =>
|
|
||||||
sendError(
|
|
||||||
new Error(`Error downloading "${song.author.name} - ${song.title}":\n ${error}`)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
win.setProgressBar(counter / playlist.items.length);
|
|
||||||
setBadge(playlist.items.length - counter);
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(error as Error);
|
|
||||||
} finally {
|
|
||||||
win.setProgressBar(-1); // Close progress bar
|
|
||||||
setBadge(0); // Close badge counter
|
|
||||||
sendFeedback(); // Clear feedback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ffmpegWriteTags(filePath: string, metadata: CustomSongInfo, presetFFmpegArgs: string[] = [], ffmpegArgs: string[] = []) {
|
|
||||||
const releaseFFmpegMutex = await ffmpegMutex.acquire();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!ffmpeg.isLoaded()) {
|
|
||||||
await ffmpeg.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ffmpeg.run(
|
|
||||||
'-i',
|
|
||||||
filePath,
|
|
||||||
...getFFmpegMetadataArgs(metadata),
|
|
||||||
...presetFFmpegArgs,
|
|
||||||
...ffmpegArgs,
|
|
||||||
filePath,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
sendError(error as Error);
|
|
||||||
} finally {
|
|
||||||
releaseFFmpegMutex();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
|
|
||||||
if (!metadata) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
...(metadata.title ? ['-metadata', `title=${metadata.title}`] : []),
|
|
||||||
...(metadata.artist ? ['-metadata', `artist=${metadata.artist}`] : []),
|
|
||||||
...(metadata.album ? ['-metadata', `album=${metadata.album}`] : []),
|
|
||||||
...(metadata.trackId ? ['-metadata', `track=${metadata.trackId}`] : []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Playlist radio modifier needs to be cut from playlist ID
|
|
||||||
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
|
|
||||||
|
|
||||||
const getPlaylistID = (aURL: URL) => {
|
|
||||||
const result
|
|
||||||
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
|
|
||||||
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
|
|
||||||
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVideoId = (url: URL | string): string | null => {
|
|
||||||
return (new URL(url)).searchParams.get('v');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
|
|
||||||
videoId: info.basic_info.id!,
|
|
||||||
title: cleanupName(info.basic_info.title!),
|
|
||||||
artist: cleanupName(info.basic_info.author!),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
|
|
||||||
album: (info.player_overlays?.browser_media_session as any)?.album?.text as string | undefined,
|
|
||||||
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
|
|
||||||
views: info.basic_info.view_count!,
|
|
||||||
songDuration: info.basic_info.duration!,
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is used to bypass age restrictions
|
|
||||||
const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
|
||||||
const innertube = await Innertube.create({
|
|
||||||
client_type: ClientType.TV_EMBEDDED,
|
|
||||||
generate_session_locally: true,
|
|
||||||
retrieve_player: true,
|
|
||||||
});
|
|
||||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
|
||||||
// that's fine as we only need the streaming data
|
|
||||||
return await innertube.getBasicInfo(id, 'TV_EMBEDDED');
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('downloader');
|
|
||||||
export default config;
|
|
||||||
@ -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="0"] #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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { dialog } from 'electron';
|
|
||||||
|
|
||||||
import { downloadPlaylist } from './back';
|
|
||||||
import { defaultMenuDownloadLabel, getFolder, presets } from './utils';
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: defaultMenuDownloadLabel,
|
|
||||||
click: () => downloadPlaylist(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Choose download folder',
|
|
||||||
click() {
|
|
||||||
const result = dialog.showOpenDialogSync({
|
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
|
||||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
config.set('downloadFolder', result[0]);
|
|
||||||
} // Else = user pressed cancel
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Presets',
|
|
||||||
submenu: Object.keys(presets).map((preset) => ({
|
|
||||||
label: preset,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('preset') === preset,
|
|
||||||
click() {
|
|
||||||
config.set('preset', preset);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Skip existing files',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('skipExisting'),
|
|
||||||
click(item) {
|
|
||||||
config.set('skipExisting', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
|
||||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
|
||||||
|
|
||||||
const exponentialVolume = () => {
|
|
||||||
// Manipulation exponent, higher value = lower volume
|
|
||||||
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
|
|
||||||
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
|
|
||||||
const EXPONENT = 3;
|
|
||||||
|
|
||||||
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
|
||||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
|
||||||
HTMLMediaElement.prototype,
|
|
||||||
'volume',
|
|
||||||
);
|
|
||||||
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
|
|
||||||
get(this: HTMLMediaElement) {
|
|
||||||
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
|
|
||||||
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
|
|
||||||
|
|
||||||
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
|
|
||||||
// To avoid this, I'll store the unmodified volume to return it when read here.
|
|
||||||
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
|
|
||||||
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
|
|
||||||
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
|
|
||||||
const storedDeviation = Math.abs(
|
|
||||||
storedOriginalVolume - calculatedOriginalVolume,
|
|
||||||
);
|
|
||||||
|
|
||||||
return storedDeviation < 0.01
|
|
||||||
? storedOriginalVolume
|
|
||||||
: calculatedOriginalVolume;
|
|
||||||
},
|
|
||||||
set(this: HTMLMediaElement, originalVolume: number) {
|
|
||||||
const lowVolume = originalVolume ** EXPONENT;
|
|
||||||
storedOriginalVolumes.set(this, originalVolume);
|
|
||||||
propertyDescriptor?.set?.call(this, lowVolume);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () =>
|
|
||||||
document.addEventListener('apiLoaded', exponentialVolume, {
|
|
||||||
once: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { register } from 'electron-localshortcut';
|
|
||||||
|
|
||||||
import { BrowserWindow, Menu, MenuItem, ipcMain } from 'electron';
|
|
||||||
|
|
||||||
import titlebarStyle from './titlebar.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
// Tracks menu visibility
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, titlebarStyle);
|
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
|
||||||
register(win, '`', () => {
|
|
||||||
win.webContents.send('toggleMenu');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
'get-menu',
|
|
||||||
() => JSON.parse(JSON.stringify(
|
|
||||||
Menu.getApplicationMenu(),
|
|
||||||
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getMenuItemById = (commandId: number): MenuItem | null => {
|
|
||||||
const menu = Menu.getApplicationMenu();
|
|
||||||
|
|
||||||
let target: MenuItem | null = null;
|
|
||||||
const stack = [...menu?.items ?? []];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const now = stack.shift();
|
|
||||||
now?.submenu?.items.forEach((item) => stack.push(item));
|
|
||||||
|
|
||||||
if (now?.commandId === commandId) {
|
|
||||||
target = now;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
ipcMain.handle('menu-event', (event, commandId: number) => {
|
|
||||||
const target = getMenuItemById(commandId);
|
|
||||||
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('get-menu-by-id', (_, commandId: number) => {
|
|
||||||
const result = getMenuItemById(commandId);
|
|
||||||
|
|
||||||
return JSON.parse(JSON.stringify(
|
|
||||||
result,
|
|
||||||
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('window-is-maximized', () => win.isMaximized());
|
|
||||||
|
|
||||||
ipcMain.handle('window-close', () => win.close());
|
|
||||||
ipcMain.handle('window-minimize', () => win.minimize());
|
|
||||||
ipcMain.handle('window-maximize', () => win.maximize());
|
|
||||||
ipcMain.handle('window-unmaximize', () => win.unmaximize());
|
|
||||||
};
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
import { ipcRenderer, Menu } from 'electron';
|
|
||||||
|
|
||||||
import { createPanel } from './menu/panel';
|
|
||||||
|
|
||||||
import logo from './assets/menu.svg';
|
|
||||||
import close from './assets/close.svg';
|
|
||||||
import minimize from './assets/minimize.svg';
|
|
||||||
import maximize from './assets/maximize.svg';
|
|
||||||
import unmaximize from './assets/unmaximize.svg';
|
|
||||||
|
|
||||||
import { isEnabled } from '../../config/plugins';
|
|
||||||
import config from '../../config';
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
|
||||||
return document.querySelector<E>(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMacOS = navigator.userAgent.includes('Macintosh');
|
|
||||||
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
let hideMenu = config.get('options.hideMenu');
|
|
||||||
const titleBar = document.createElement('title-bar');
|
|
||||||
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
|
||||||
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
|
|
||||||
|
|
||||||
logo.classList.add('title-bar-icon');
|
|
||||||
const logoClick = () => {
|
|
||||||
hideMenu = !hideMenu;
|
|
||||||
let visibilityStyle: string;
|
|
||||||
if (hideMenu) {
|
|
||||||
visibilityStyle = 'hidden';
|
|
||||||
} else {
|
|
||||||
visibilityStyle = 'visible';
|
|
||||||
}
|
|
||||||
const menus = document.querySelectorAll<HTMLElement>('menu-button');
|
|
||||||
menus.forEach((menu) => {
|
|
||||||
menu.style.visibility = visibilityStyle;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
logo.onclick = logoClick;
|
|
||||||
|
|
||||||
ipcRenderer.on('toggleMenu', logoClick);
|
|
||||||
|
|
||||||
if (!isMacOS) titleBar.appendChild(logo);
|
|
||||||
document.body.appendChild(titleBar);
|
|
||||||
|
|
||||||
titleBar.appendChild(logo);
|
|
||||||
|
|
||||||
const addWindowControls = async () => {
|
|
||||||
|
|
||||||
// Create window control buttons
|
|
||||||
const minimizeButton = document.createElement('button');
|
|
||||||
minimizeButton.classList.add('window-control');
|
|
||||||
minimizeButton.appendChild(minimize);
|
|
||||||
minimizeButton.onclick = () => ipcRenderer.invoke('window-minimize');
|
|
||||||
|
|
||||||
const maximizeButton = document.createElement('button');
|
|
||||||
if (await ipcRenderer.invoke('window-is-maximized')) {
|
|
||||||
maximizeButton.classList.add('window-control');
|
|
||||||
maximizeButton.appendChild(unmaximize);
|
|
||||||
} else {
|
|
||||||
maximizeButton.classList.add('window-control');
|
|
||||||
maximizeButton.appendChild(maximize);
|
|
||||||
}
|
|
||||||
maximizeButton.onclick = async () => {
|
|
||||||
if (await ipcRenderer.invoke('window-is-maximized')) {
|
|
||||||
// change icon to maximize
|
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
|
||||||
maximizeButton.appendChild(maximize);
|
|
||||||
|
|
||||||
// call unmaximize
|
|
||||||
await ipcRenderer.invoke('window-unmaximize');
|
|
||||||
} else {
|
|
||||||
// change icon to unmaximize
|
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
|
||||||
maximizeButton.appendChild(unmaximize);
|
|
||||||
|
|
||||||
// call maximize
|
|
||||||
await ipcRenderer.invoke('window-maximize');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeButton = document.createElement('button');
|
|
||||||
closeButton.classList.add('window-control');
|
|
||||||
closeButton.appendChild(close);
|
|
||||||
closeButton.onclick = () => ipcRenderer.invoke('window-close');
|
|
||||||
|
|
||||||
// Create a container div for the window control buttons
|
|
||||||
const windowControlsContainer = document.createElement('div');
|
|
||||||
windowControlsContainer.classList.add('window-controls-container');
|
|
||||||
windowControlsContainer.appendChild(minimizeButton);
|
|
||||||
windowControlsContainer.appendChild(maximizeButton);
|
|
||||||
windowControlsContainer.appendChild(closeButton);
|
|
||||||
|
|
||||||
// Add window control buttons to the title bar
|
|
||||||
titleBar.appendChild(windowControlsContainer);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNotWindowsOrMacOS) await addWindowControls();
|
|
||||||
|
|
||||||
if (navBar) {
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach(() => {
|
|
||||||
titleBar.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
|
|
||||||
document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(navBar, { attributes : true, attributeFilter : ['style'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMenu = async () => {
|
|
||||||
const children = [...titleBar.children];
|
|
||||||
children.forEach((child) => {
|
|
||||||
if (child !== logo) child.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = await ipcRenderer.invoke('get-menu') as Menu | null;
|
|
||||||
if (!menu) return;
|
|
||||||
|
|
||||||
menu.items.forEach((menuItem) => {
|
|
||||||
const menu = document.createElement('menu-button');
|
|
||||||
createPanel(titleBar, menu, menuItem.submenu?.items ?? []);
|
|
||||||
|
|
||||||
menu.append(menuItem.label);
|
|
||||||
titleBar.appendChild(menu);
|
|
||||||
if (hideMenu) {
|
|
||||||
menu.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (isNotWindowsOrMacOS) await addWindowControls();
|
|
||||||
};
|
|
||||||
await updateMenu();
|
|
||||||
|
|
||||||
document.title = 'Youtube Music';
|
|
||||||
|
|
||||||
ipcRenderer.on('refreshMenu', () => {
|
|
||||||
updateMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEnabled('picture-in-picture')) {
|
|
||||||
ipcRenderer.on('pip-toggle', () => {
|
|
||||||
updateMenu();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
|
||||||
const htmlHeadStyle = $('head > div > style');
|
|
||||||
if (htmlHeadStyle) {
|
|
||||||
// HACK: This is a hack to remove the scrollbar width
|
|
||||||
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
|
|
||||||
}
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
const Icons = {
|
|
||||||
submenu: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><polyline points="9 6 15 12 9 18" /></svg>',
|
|
||||||
checkbox: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg>',
|
|
||||||
radio: {
|
|
||||||
checked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
|
|
||||||
unchecked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Icons;
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import { nativeImage, type MenuItem, ipcRenderer, Menu } from 'electron';
|
|
||||||
|
|
||||||
import Icons from './icons';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../../utils';
|
|
||||||
|
|
||||||
interface PanelOptions {
|
|
||||||
placement?: 'bottom' | 'right';
|
|
||||||
order?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createPanel = (
|
|
||||||
parent: HTMLElement,
|
|
||||||
anchor: HTMLElement,
|
|
||||||
items: MenuItem[],
|
|
||||||
options: PanelOptions = { placement: 'bottom', order: 0 },
|
|
||||||
) => {
|
|
||||||
const childPanels: HTMLElement[] = [];
|
|
||||||
const panel = document.createElement('menu-panel');
|
|
||||||
panel.style.zIndex = `${options.order}`;
|
|
||||||
|
|
||||||
const updateIconState = (iconWrapper: HTMLElement, item: MenuItem) => {
|
|
||||||
if (item.type === 'checkbox') {
|
|
||||||
if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
|
|
||||||
else iconWrapper.innerHTML = '';
|
|
||||||
} else if (item.type === 'radio') {
|
|
||||||
if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
|
|
||||||
else iconWrapper.innerHTML = Icons.radio.unchecked;
|
|
||||||
} else {
|
|
||||||
const nativeImageIcon = typeof item.icon === 'string' ? nativeImage.createFromPath(item.icon) : item.icon;
|
|
||||||
const iconURL = nativeImageIcon?.toDataURL();
|
|
||||||
|
|
||||||
if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const radioGroups: [MenuItem, HTMLElement][] = [];
|
|
||||||
items.map((item) => {
|
|
||||||
if (item.type === 'separator') return panel.appendChild(document.createElement('menu-separator'));
|
|
||||||
|
|
||||||
const menu = document.createElement('menu-item');
|
|
||||||
const iconWrapper = document.createElement('menu-icon');
|
|
||||||
|
|
||||||
updateIconState(iconWrapper, item);
|
|
||||||
menu.appendChild(iconWrapper);
|
|
||||||
menu.append(item.label);
|
|
||||||
|
|
||||||
menu.addEventListener('click', async () => {
|
|
||||||
await ipcRenderer.invoke('menu-event', item.commandId);
|
|
||||||
const menuItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
|
|
||||||
|
|
||||||
if (menuItem) {
|
|
||||||
updateIconState(iconWrapper, menuItem);
|
|
||||||
|
|
||||||
if (menuItem.type === 'radio') {
|
|
||||||
await Promise.all(
|
|
||||||
radioGroups.map(async ([item, iconWrapper]) => {
|
|
||||||
if (item.commandId === menuItem.commandId) return;
|
|
||||||
const newItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
|
|
||||||
|
|
||||||
if (newItem) updateIconState(iconWrapper, newItem);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item.type === 'radio') {
|
|
||||||
radioGroups.push([item, iconWrapper]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'submenu') {
|
|
||||||
const subMenuIcon = document.createElement('menu-icon');
|
|
||||||
subMenuIcon.appendChild(ElementFromHtml(Icons.submenu));
|
|
||||||
menu.appendChild(subMenuIcon);
|
|
||||||
|
|
||||||
const [child, , children] = createPanel(parent, menu, item.submenu?.items ?? [], {
|
|
||||||
placement: 'right',
|
|
||||||
order: (options?.order ?? 0) + 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
childPanels.push(child);
|
|
||||||
children.push(...children);
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.appendChild(menu);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* methods */
|
|
||||||
const isOpened = () => panel.getAttribute('open') === 'true';
|
|
||||||
const close = () => panel.setAttribute('open', 'false');
|
|
||||||
const open = () => {
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (options.placement === 'bottom') {
|
|
||||||
panel.style.setProperty('--x', `${rect.x}px`);
|
|
||||||
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
|
|
||||||
} else {
|
|
||||||
panel.style.setProperty('--x', `${rect.x + rect.width}px`);
|
|
||||||
panel.style.setProperty('--y', `${rect.y}px`);
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.setAttribute('open', 'true');
|
|
||||||
|
|
||||||
// Children are placed below their parent item, which can cause
|
|
||||||
// long lists to squeeze their children at the bottom of the screen
|
|
||||||
// (This needs to be done *after* setAttribute)
|
|
||||||
panel.classList.remove('position-by-bottom');
|
|
||||||
if (options.placement === 'right' && panel.scrollHeight > panel.clientHeight ) {
|
|
||||||
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
|
|
||||||
panel.classList.add('position-by-bottom');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
anchor.addEventListener('click', () => {
|
|
||||||
if (isOpened()) close();
|
|
||||||
else open();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('click', (event) => {
|
|
||||||
const path = event.composedPath();
|
|
||||||
const isInside = path.some((it) => it === panel || it === anchor || childPanels.includes(it as HTMLElement));
|
|
||||||
|
|
||||||
if (!isInside) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
parent.appendChild(panel);
|
|
||||||
|
|
||||||
return [
|
|
||||||
panel,
|
|
||||||
{ isOpened, close, open },
|
|
||||||
childPanels,
|
|
||||||
] as const;
|
|
||||||
};
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
import { BrowserWindow, net, shell } from 'electron';
|
|
||||||
|
|
||||||
import { setOptions } from '../../config/plugins';
|
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
|
||||||
import defaultConfig from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
type LastFMOptions = ConfigType<'last-fm'>;
|
|
||||||
|
|
||||||
interface LastFmData {
|
|
||||||
method: string,
|
|
||||||
timestamp?: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LastFmSongData {
|
|
||||||
track?: string,
|
|
||||||
duration?: number,
|
|
||||||
artist?: string,
|
|
||||||
album?: string,
|
|
||||||
api_key: string,
|
|
||||||
sk?: string,
|
|
||||||
format: string,
|
|
||||||
method: string,
|
|
||||||
timestamp?: number,
|
|
||||||
api_sig?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const createFormData = (parameters: LastFmSongData) => {
|
|
||||||
// Creates the body for in the post request
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
for (const key in parameters) {
|
|
||||||
formData.append(key, String(parameters[key as keyof LastFmSongData]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createQueryString = (parameters: Record<string, unknown>, apiSignature: string) => {
|
|
||||||
// Creates a querystring
|
|
||||||
const queryData = [];
|
|
||||||
parameters.api_sig = apiSignature;
|
|
||||||
for (const key in parameters) {
|
|
||||||
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '?' + queryData.join('&');
|
|
||||||
};
|
|
||||||
|
|
||||||
const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
|
||||||
// This function creates the api signature, see: https://www.last.fm/api/authspec
|
|
||||||
const keys = Object.keys(parameters);
|
|
||||||
|
|
||||||
keys.sort();
|
|
||||||
let sig = '';
|
|
||||||
for (const key of keys) {
|
|
||||||
if (String(key) === 'format') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sig += `${key}${parameters[key as keyof LastFmSongData]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
sig += secret;
|
|
||||||
sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
|
|
||||||
return sig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => {
|
|
||||||
// Creates and stores the auth token
|
|
||||||
const data = {
|
|
||||||
method: 'auth.gettoken',
|
|
||||||
api_key: apiKey,
|
|
||||||
format: 'json',
|
|
||||||
};
|
|
||||||
const apiSigature = createApiSig(data, secret);
|
|
||||||
const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
|
|
||||||
const json = await response.json() as Record<string, string>;
|
|
||||||
return json?.token;
|
|
||||||
};
|
|
||||||
|
|
||||||
const authenticate = async (config: LastFMOptions) => {
|
|
||||||
// Asks the user for authentication
|
|
||||||
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAndSetSessionKey = async (config: LastFMOptions) => {
|
|
||||||
// Get and store the session key
|
|
||||||
const data = {
|
|
||||||
api_key: config.api_key,
|
|
||||||
format: 'json',
|
|
||||||
method: 'auth.getsession',
|
|
||||||
token: config.token,
|
|
||||||
};
|
|
||||||
const apiSignature = createApiSig(data, config.secret);
|
|
||||||
const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
|
|
||||||
const json = await response.json() as {
|
|
||||||
error?: string,
|
|
||||||
session?: {
|
|
||||||
key: string,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (json.error) {
|
|
||||||
config.token = await createToken(config);
|
|
||||||
await authenticate(config);
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
}
|
|
||||||
if (json.session) {
|
|
||||||
config.session_key = json.session.key;
|
|
||||||
}
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => {
|
|
||||||
// This sends a post request to the api, and adds the common data
|
|
||||||
if (!config.session_key) {
|
|
||||||
await getAndSetSessionKey(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
const postData: LastFmSongData = {
|
|
||||||
track: songInfo.title,
|
|
||||||
duration: songInfo.songDuration,
|
|
||||||
artist: songInfo.artist,
|
|
||||||
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
|
|
||||||
api_key: config.api_key,
|
|
||||||
sk: config.session_key,
|
|
||||||
format: 'json',
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
postData.api_sig = createApiSig(postData, config.secret);
|
|
||||||
const formData = createFormData(postData);
|
|
||||||
net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: formData })
|
|
||||||
.catch(async (error: {
|
|
||||||
response?: {
|
|
||||||
data?: {
|
|
||||||
error: number,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) => {
|
|
||||||
if (error?.response?.data?.error === 9) {
|
|
||||||
// Session key is invalid, so remove it from the config and reauthenticate
|
|
||||||
config.session_key = undefined;
|
|
||||||
config.token = await createToken(config);
|
|
||||||
await authenticate(config);
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
|
|
||||||
// This adds one scrobbled song to last.fm
|
|
||||||
const data = {
|
|
||||||
method: 'track.scrobble',
|
|
||||||
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
|
||||||
};
|
|
||||||
postSongDataToAPI(songInfo, config, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
|
|
||||||
// This sets the now playing status in last.fm
|
|
||||||
const data = {
|
|
||||||
method: 'track.updateNowPlaying',
|
|
||||||
};
|
|
||||||
postSongDataToAPI(songInfo, config, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
// This will store the timeout that will trigger addScrobble
|
|
||||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
|
|
||||||
if (!config.api_root) {
|
|
||||||
// Settings are not present, creating them with the default values
|
|
||||||
config = defaultConfig.plugins['last-fm'];
|
|
||||||
config.enabled = true;
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.session_key) {
|
|
||||||
// Not authenticated
|
|
||||||
config = await getAndSetSessionKey(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Set remove the old scrobble timer
|
|
||||||
clearTimeout(scrobbleTimer);
|
|
||||||
if (!songInfo.isPaused) {
|
|
||||||
setNowPlaying(songInfo, config);
|
|
||||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
|
||||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
|
||||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
|
||||||
// Scrobble still needs to happen
|
|
||||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
|
||||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default lastfm;
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { BrowserWindow , net } from 'electron';
|
|
||||||
|
|
||||||
import registerCallback from '../../providers/song-info';
|
|
||||||
|
|
||||||
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
|
|
||||||
const previousStatePaused = null;
|
|
||||||
|
|
||||||
type LumiaData = {
|
|
||||||
origin: string;
|
|
||||||
eventType: string;
|
|
||||||
url?: string;
|
|
||||||
videoId?: string;
|
|
||||||
playlistId?: string;
|
|
||||||
cover?: string|null;
|
|
||||||
cover_url?: string|null;
|
|
||||||
title?: string;
|
|
||||||
artists?: string[];
|
|
||||||
status?: string;
|
|
||||||
progress?: number;
|
|
||||||
duration?: number;
|
|
||||||
album_url?: string|null;
|
|
||||||
album?: string|null;
|
|
||||||
views?: number;
|
|
||||||
isPaused?: boolean;
|
|
||||||
}
|
|
||||||
const data: LumiaData = {
|
|
||||||
origin: 'youtubemusic',
|
|
||||||
eventType: 'switchSong',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const post = (data: LumiaData) => {
|
|
||||||
const port = 39231;
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Access-Control-Allow-Headers': '*',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
};
|
|
||||||
const url = `http://localhost:${port}/api/media`;
|
|
||||||
|
|
||||||
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
|
|
||||||
.catch((error: { code: number, errno: number }) => {
|
|
||||||
console.log(
|
|
||||||
`Error: '${
|
|
||||||
error.code || error.errno
|
|
||||||
}' - when trying to access lumiastream webserver at port ${port}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (_: BrowserWindow) => {
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
if (!songInfo.title && !songInfo.artist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousStatePaused === null) {
|
|
||||||
data.eventType = 'switchSong';
|
|
||||||
} else if (previousStatePaused !== songInfo.isPaused) {
|
|
||||||
data.eventType = 'playPause';
|
|
||||||
}
|
|
||||||
|
|
||||||
data.duration = secToMilisec(songInfo.songDuration);
|
|
||||||
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
|
||||||
data.url = songInfo.url;
|
|
||||||
data.videoId = songInfo.videoId;
|
|
||||||
data.playlistId = songInfo.playlistId;
|
|
||||||
data.cover = songInfo.imageSrc;
|
|
||||||
data.cover_url = songInfo.imageSrc;
|
|
||||||
data.album_url = songInfo.imageSrc;
|
|
||||||
data.title = songInfo.title;
|
|
||||||
data.artists = [songInfo.artist];
|
|
||||||
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
|
||||||
data.isPaused = songInfo.isPaused;
|
|
||||||
data.album = songInfo.album;
|
|
||||||
data.views = songInfo.views;
|
|
||||||
post(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import { LyricGeniusType, toggleRomanized } from './back';
|
|
||||||
|
|
||||||
import { setOptions } from '../../config/plugins';
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Romanized Lyrics',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.romanizedLyrics,
|
|
||||||
click(item: MenuItem) {
|
|
||||||
options.romanizedLyrics = item.checked;
|
|
||||||
setOptions('lyrics-genius', options);
|
|
||||||
toggleRomanized();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
export function handle(win: BrowserWindow) {
|
|
||||||
injectCSS(win.webContents, style, () => {
|
|
||||||
win.webContents.send('navigation-css-ready');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default handle;
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import forwardHTML from './templates/forward.html';
|
|
||||||
import backHTML from './templates/back.html';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
|
|
||||||
export function run() {
|
|
||||||
ipcRenderer.on('navigation-css-ready', () => {
|
|
||||||
const forwardButton = ElementFromHtml(forwardHTML);
|
|
||||||
const backButton = ElementFromHtml(backHTML);
|
|
||||||
const menu = document.querySelector('#right-content');
|
|
||||||
|
|
||||||
if (menu) {
|
|
||||||
menu.prepend(backButton, forwardButton);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default run;
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<div
|
|
||||||
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
|
|
||||||
onclick="history.back()"
|
|
||||||
role="tab"
|
|
||||||
tab-id="FEmusic_back"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-disabled="false"
|
|
||||||
class="search-icon style-scope ytmusic-search-box"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Go to previous page"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="tab-icon style-scope paper-icon-button navigation-icon"
|
|
||||||
id="icon"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="style-scope iron-icon"
|
|
||||||
focusable="false"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
|
||||||
viewBox="0 0 492 492"
|
|
||||||
>
|
|
||||||
<g class="style-scope iron-icon">
|
|
||||||
<path
|
|
||||||
d="M109.3 265.2l218.9 218.9c5.1 5.1 11.8 7.9 19 7.9s14-2.8 19-7.9l16.1-16.1c10.5-10.5 10.5-27.6 0-38.1L198.6 246.1 382.7 62c5.1-5.1 7.9-11.8 7.9-19 0-7.2-2.8-14-7.9-19L366.5 7.9c-5.1-5.1-11.8-7.9-19-7.9-7.2 0-14 2.8-19 7.9L109.3 227c-5.1 5.1-7.9 11.9-7.8 19.1 0 7.2 2.8 14 7.8 19.1z"
|
|
||||||
></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
function removeLoginElements() {
|
|
||||||
const elementsToRemove = [
|
|
||||||
'.sign-in-link.ytmusic-nav-bar',
|
|
||||||
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const selector of elementsToRemove) {
|
|
||||||
const node = document.querySelector(selector);
|
|
||||||
if (node) {
|
|
||||||
node.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the library button
|
|
||||||
const libraryIconPath
|
|
||||||
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const menuEntries = document.querySelectorAll(
|
|
||||||
'#items ytmusic-guide-entry-renderer',
|
|
||||||
);
|
|
||||||
menuEntries.forEach((item) => {
|
|
||||||
const icon = item.querySelector('path');
|
|
||||||
if (icon) {
|
|
||||||
observer.disconnect();
|
|
||||||
if (icon.getAttribute('d') === libraryIconPath) {
|
|
||||||
item.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default removeLoginElements;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('notifications');
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { app, BrowserWindow, ipcMain, Notification } from 'electron';
|
|
||||||
|
|
||||||
import { icons, notificationImage, saveTempIcon, secondsToMinutes, ToastStyles } from './utils';
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
|
||||||
import { changeProtocolHandler } from '../../providers/protocol-handler';
|
|
||||||
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
|
|
||||||
import { getMediaIconLocation } from '../utils';
|
|
||||||
|
|
||||||
let songControls: ReturnType<typeof getSongControls>;
|
|
||||||
let savedNotification: Notification | undefined;
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
songControls = getSongControls(win);
|
|
||||||
|
|
||||||
let currentSeconds = 0;
|
|
||||||
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
|
|
||||||
|
|
||||||
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
|
||||||
saveTempIcon();
|
|
||||||
}
|
|
||||||
|
|
||||||
let savedSongInfo: SongInfo;
|
|
||||||
let lastUrl: string | undefined;
|
|
||||||
|
|
||||||
// Register songInfoCallback
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
if (!songInfo.artist && !songInfo.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
savedSongInfo = { ...songInfo };
|
|
||||||
if (!songInfo.isPaused
|
|
||||||
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
|
|
||||||
) {
|
|
||||||
lastUrl = songInfo.url;
|
|
||||||
sendNotification(songInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.get('trayControls')) {
|
|
||||||
setTrayOnClick(() => {
|
|
||||||
if (savedNotification) {
|
|
||||||
savedNotification.close();
|
|
||||||
savedNotification = undefined;
|
|
||||||
} else if (savedSongInfo) {
|
|
||||||
sendNotification({
|
|
||||||
...savedSongInfo,
|
|
||||||
elapsedSeconds: currentSeconds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTrayOnDoubleClick(() => {
|
|
||||||
if (win.isVisible()) {
|
|
||||||
win.hide();
|
|
||||||
} else {
|
|
||||||
win.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.once('before-quit', () => {
|
|
||||||
savedNotification?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
changeProtocolHandler(
|
|
||||||
(cmd) => {
|
|
||||||
if (Object.keys(songControls).includes(cmd)) {
|
|
||||||
songControls[cmd as keyof typeof songControls]();
|
|
||||||
if (config.get('refreshOnPlayPause') && (
|
|
||||||
cmd === 'pause'
|
|
||||||
|| (cmd === 'play' && !config.get('unpauseNotification'))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setImmediate(() =>
|
|
||||||
sendNotification({
|
|
||||||
...savedSongInfo,
|
|
||||||
isPaused: cmd === 'pause',
|
|
||||||
elapsedSeconds: currentSeconds,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function sendNotification(songInfo: SongInfo) {
|
|
||||||
const iconSrc = notificationImage(songInfo);
|
|
||||||
|
|
||||||
savedNotification?.close();
|
|
||||||
|
|
||||||
let icon: string;
|
|
||||||
if (typeof iconSrc === 'object') {
|
|
||||||
icon = iconSrc.toDataURL();
|
|
||||||
} else {
|
|
||||||
icon = iconSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
savedNotification = new Notification({
|
|
||||||
title: songInfo.title || 'Playing',
|
|
||||||
body: songInfo.artist,
|
|
||||||
icon: iconSrc,
|
|
||||||
silent: true,
|
|
||||||
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
|
|
||||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
|
|
||||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
|
|
||||||
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
|
|
||||||
toastXml: getXml(songInfo, icon),
|
|
||||||
});
|
|
||||||
|
|
||||||
savedNotification.on('close', () => {
|
|
||||||
savedNotification = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
savedNotification.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
const getXml = (songInfo: SongInfo, iconSrc: string) => {
|
|
||||||
switch (config.get('toastStyle')) {
|
|
||||||
default:
|
|
||||||
case ToastStyles.logo:
|
|
||||||
case ToastStyles.legacy: {
|
|
||||||
return xmlLogo(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_top_custom: {
|
|
||||||
return xmlBannerTopCustom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.hero: {
|
|
||||||
return xmlHero(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_bottom: {
|
|
||||||
return xmlBannerBottom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_centered_bottom: {
|
|
||||||
return xmlBannerCenteredBottom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_centered_top: {
|
|
||||||
return xmlBannerCenteredTop(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const display = (kind: keyof typeof icons) => {
|
|
||||||
if (config.get('toastStyle') === ToastStyles.legacy) {
|
|
||||||
return `content="${icons[kind]}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `\
|
|
||||||
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
|
|
||||||
imageUri="file:///${path.resolve(getMediaIconLocation(), `${kind}.png`)}"
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButton = (kind: keyof typeof icons) =>
|
|
||||||
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
|
|
||||||
|
|
||||||
const getButtons = (isPaused: boolean) => `\
|
|
||||||
<actions>
|
|
||||||
${getButton('previous')}
|
|
||||||
${isPaused ? getButton('play') : getButton('pause')}
|
|
||||||
${getButton('next')}
|
|
||||||
</actions>\
|
|
||||||
`;
|
|
||||||
|
|
||||||
const toast = (content: string, isPaused: boolean) => `\
|
|
||||||
<toast>
|
|
||||||
<audio silent="true" />
|
|
||||||
<visual>
|
|
||||||
<binding template="ToastGeneric">
|
|
||||||
${content}
|
|
||||||
</binding>
|
|
||||||
</visual>
|
|
||||||
|
|
||||||
${getButtons(isPaused)}
|
|
||||||
</toast>`;
|
|
||||||
|
|
||||||
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
|
|
||||||
<text id="1">${title}</text>
|
|
||||||
<text id="2">${artist}</text>\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
|
|
||||||
|
|
||||||
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
|
|
||||||
|
|
||||||
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
|
|
||||||
|
|
||||||
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" />
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup>
|
|
||||||
<text hint-style="body">${songInfo.title}</text>
|
|
||||||
<text hint-style="captionSubtle">${songInfo.artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
${xmlMoreData(songInfo)}
|
|
||||||
</group>\
|
|
||||||
`, songInfo.isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
|
|
||||||
<subgroup hint-textStacking="bottom">
|
|
||||||
${album
|
|
||||||
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
|
|
||||||
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
|
|
||||||
</subgroup>\
|
|
||||||
`;
|
|
||||||
|
|
||||||
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup hint-weight="1" hint-textStacking="center">
|
|
||||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
|
||||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
</group>
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" />
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup hint-weight="1" hint-textStacking="center">
|
|
||||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
|
||||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
</group>\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const titleFontPicker = (title: string) => {
|
|
||||||
if (title.length <= 13) {
|
|
||||||
return 'Header';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title.length <= 22) {
|
|
||||||
return 'Subheader';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title.length <= 26) {
|
|
||||||
return 'Title';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Subtitle';
|
|
||||||
};
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => {
|
|
||||||
if (is.linux()) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Notification Priority',
|
|
||||||
submenu: urgencyLevels.map((level) => ({
|
|
||||||
label: level.name,
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.urgency === level.value,
|
|
||||||
click: () => config.set('urgency', level.value),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
];
|
|
||||||
} else if (is.windows()) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Interactive Notifications',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.interactive,
|
|
||||||
// Doesn't update until restart
|
|
||||||
click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Submenu with settings for interactive notifications (name shouldn't be too long)
|
|
||||||
label: 'Interactive Settings',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Open/Close on tray click',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.trayControls,
|
|
||||||
click: (item: MenuItem) => config.set('trayControls', item.checked),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hide Button Text',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.hideButtonText,
|
|
||||||
click: (item: MenuItem) => config.set('hideButtonText', item.checked),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Refresh on Play/Pause',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.refreshOnPlayPause,
|
|
||||||
click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Style',
|
|
||||||
submenu: getToastStyleMenuItems(options),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
|
|
||||||
...getMenu(options),
|
|
||||||
{
|
|
||||||
label: 'Show notification on unpause',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.unpauseNotification,
|
|
||||||
click: (item: MenuItem) => config.set('unpauseNotification', item.checked),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
|
|
||||||
const array = Array.from({ length: Object.keys(ToastStyles).length });
|
|
||||||
|
|
||||||
// ToastStyles index starts from 1
|
|
||||||
for (const [name, index] of Object.entries(ToastStyles)) {
|
|
||||||
array[index - 1] = {
|
|
||||||
label: snakeToCamel(name),
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.toastStyle === index,
|
|
||||||
click: () => config.set('toastStyle', index),
|
|
||||||
} satisfies Electron.MenuItemConstructorOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array as Electron.MenuItemConstructorOptions[];
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
import { setOptions as setPluginOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
let isInPiP = false;
|
|
||||||
let originalPosition: number[];
|
|
||||||
let originalSize: number[];
|
|
||||||
let originalFullScreen: boolean;
|
|
||||||
let originalMaximized: boolean;
|
|
||||||
|
|
||||||
let win: BrowserWindow;
|
|
||||||
|
|
||||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
|
||||||
|
|
||||||
let options: Partial<PiPOptions>;
|
|
||||||
|
|
||||||
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
|
|
||||||
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
|
|
||||||
|
|
||||||
const setLocalOptions = (_options: Partial<PiPOptions>) => {
|
|
||||||
options = { ...options, ..._options };
|
|
||||||
setPluginOptions('picture-in-picture', _options);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePiP = () => {
|
|
||||||
isInPiP = !isInPiP;
|
|
||||||
setLocalOptions({ isInPiP });
|
|
||||||
|
|
||||||
if (isInPiP) {
|
|
||||||
originalFullScreen = win.isFullScreen();
|
|
||||||
if (originalFullScreen) {
|
|
||||||
win.setFullScreen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
originalMaximized = win.isMaximized();
|
|
||||||
if (originalMaximized) {
|
|
||||||
win.unmaximize();
|
|
||||||
}
|
|
||||||
|
|
||||||
originalPosition = win.getPosition();
|
|
||||||
originalSize = win.getSize();
|
|
||||||
|
|
||||||
win.webContents.on('before-input-event', blockShortcutsInPiP);
|
|
||||||
|
|
||||||
win.setMaximizable(false);
|
|
||||||
win.setFullScreenable(false);
|
|
||||||
|
|
||||||
win.webContents.send('pip-toggle', true);
|
|
||||||
|
|
||||||
app.dock?.hide();
|
|
||||||
win.setVisibleOnAllWorkspaces(true, {
|
|
||||||
visibleOnFullScreen: true,
|
|
||||||
});
|
|
||||||
app.dock?.show();
|
|
||||||
if (options.alwaysOnTop) {
|
|
||||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
|
||||||
win.setMaximizable(true);
|
|
||||||
win.setFullScreenable(true);
|
|
||||||
|
|
||||||
win.webContents.send('pip-toggle', false);
|
|
||||||
|
|
||||||
win.setVisibleOnAllWorkspaces(false);
|
|
||||||
win.setAlwaysOnTop(false);
|
|
||||||
|
|
||||||
if (originalFullScreen) {
|
|
||||||
win.setFullScreen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalMaximized) {
|
|
||||||
win.maximize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
|
||||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
|
||||||
win.setPosition(x, y);
|
|
||||||
win.setSize(w, h);
|
|
||||||
|
|
||||||
win.setWindowButtonVisibility?.(!isInPiP);
|
|
||||||
};
|
|
||||||
|
|
||||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
|
||||||
const key = input.key.toLowerCase();
|
|
||||||
|
|
||||||
if (key === 'f') {
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (key === 'escape') {
|
|
||||||
togglePiP();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (_win: BrowserWindow, _options: PiPOptions) => {
|
|
||||||
options ??= _options;
|
|
||||||
win ??= _win;
|
|
||||||
setLocalOptions({ isInPiP });
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
ipcMain.on('picture-in-picture', () => {
|
|
||||||
togglePiP();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setOptions = setLocalOptions;
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { setOptions } from './back';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Always on top',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.alwaysOnTop,
|
|
||||||
click(item) {
|
|
||||||
setOptions({ alwaysOnTop: item.checked });
|
|
||||||
win.setAlwaysOnTop(item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Save window position',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.savePosition,
|
|
||||||
click(item) {
|
|
||||||
setOptions({ savePosition: item.checked });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Save window size',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.saveSize,
|
|
||||||
click(item) {
|
|
||||||
setOptions({ saveSize: item.checked });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hotkey',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: !!options.hotkey,
|
|
||||||
async click(item) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Picture in Picture Hotkey',
|
|
||||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
|
||||||
type: 'keybind',
|
|
||||||
keybindOptions: [{
|
|
||||||
value: 'hotkey',
|
|
||||||
label: 'Hotkey',
|
|
||||||
default: options.hotkey,
|
|
||||||
}],
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
|
||||||
const { value, accelerator } = output[0];
|
|
||||||
setOptions({ [value]: accelerator });
|
|
||||||
|
|
||||||
item.checked = !!accelerator;
|
|
||||||
} else {
|
|
||||||
// Reset checkbox if prompt was canceled
|
|
||||||
item.checked = !item.checked;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Use native PiP',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.useNativePiP,
|
|
||||||
click(item) {
|
|
||||||
setOptions({ useNativePiP: item.checked });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import sliderHTML from './templates/slider.html';
|
|
||||||
|
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
import { singleton } from '../../providers/decorators';
|
|
||||||
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
|
||||||
return document.querySelector<E>(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slider = ElementFromHtml(sliderHTML);
|
|
||||||
|
|
||||||
const roundToTwo = (n: number) => Math.round(n * 1e2) / 1e2;
|
|
||||||
|
|
||||||
const MIN_PLAYBACK_SPEED = 0.07;
|
|
||||||
const MAX_PLAYBACK_SPEED = 16;
|
|
||||||
|
|
||||||
let playbackSpeed = 1;
|
|
||||||
|
|
||||||
const updatePlayBackSpeed = () => {
|
|
||||||
const videoElement = $<HTMLVideoElement>('video');
|
|
||||||
if (videoElement) {
|
|
||||||
videoElement.playbackRate = playbackSpeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playbackSpeedElement = $('#playback-speed-value');
|
|
||||||
if (playbackSpeedElement) {
|
|
||||||
playbackSpeedElement.innerHTML = String(playbackSpeed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let menu: Element | null = null;
|
|
||||||
|
|
||||||
const setupSliderListener = singleton(() => {
|
|
||||||
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
|
|
||||||
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
|
|
||||||
if (isNaN(playbackSpeed)) {
|
|
||||||
playbackSpeed = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePlayBackSpeed();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const observePopupContainer = () => {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (!menu) {
|
|
||||||
menu = getSongMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
menu &&
|
|
||||||
(menu.parentElement as HTMLElement & { eventSink_: Element | null })
|
|
||||||
?.eventSink_
|
|
||||||
?.matches('ytmusic-menu-renderer.ytmusic-player-bar')&& !menu.contains(slider)
|
|
||||||
) {
|
|
||||||
menu.prepend(slider);
|
|
||||||
setupSliderListener();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const popupContainer = $('ytmusic-popup-container');
|
|
||||||
if (popupContainer) {
|
|
||||||
observer.observe(popupContainer, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const observeVideo = () => {
|
|
||||||
const video = $<HTMLVideoElement>('video');
|
|
||||||
if (video) {
|
|
||||||
video.addEventListener('ratechange', forcePlaybackRate);
|
|
||||||
video.addEventListener('srcChanged', forcePlaybackRate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupWheelListener = () => {
|
|
||||||
slider.addEventListener('wheel', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isNaN(playbackSpeed)) {
|
|
||||||
playbackSpeed = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// E.deltaY < 0 means wheel-up
|
|
||||||
playbackSpeed = roundToTwo(e.deltaY < 0
|
|
||||||
? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED)
|
|
||||||
: Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED),
|
|
||||||
);
|
|
||||||
|
|
||||||
updatePlayBackSpeed();
|
|
||||||
// Update slider position
|
|
||||||
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider');
|
|
||||||
if (playbackSpeedSilder) {
|
|
||||||
playbackSpeedSilder.value = playbackSpeed;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function forcePlaybackRate(e: Event) {
|
|
||||||
if (e.target instanceof HTMLVideoElement) {
|
|
||||||
const videoElement = e.target;
|
|
||||||
if (videoElement.playbackRate !== playbackSpeed) {
|
|
||||||
videoElement.playbackRate = playbackSpeed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
|
||||||
observePopupContainer();
|
|
||||||
observeVideo();
|
|
||||||
setupWheelListener();
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { globalShortcut, BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import volumeHudStyle from './volume-hud.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
/*
|
|
||||||
This is used to determine if plugin is actually active
|
|
||||||
(not if it's only enabled in options)
|
|
||||||
*/
|
|
||||||
let isEnabled = false;
|
|
||||||
|
|
||||||
export const enabled = () => isEnabled;
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
|
|
||||||
isEnabled = true;
|
|
||||||
injectCSS(win.webContents, volumeHudStyle);
|
|
||||||
|
|
||||||
if (options.globalShortcuts?.volumeUp) {
|
|
||||||
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.globalShortcuts?.volumeDown) {
|
|
||||||
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import { setOptions, setMenuOptions, isEnabled } from '../../config/plugins';
|
|
||||||
import { debounce } from '../../providers/decorators';
|
|
||||||
|
|
||||||
import { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
|
||||||
return document.querySelector<E>(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
let api: YoutubePlayer;
|
|
||||||
let options: ConfigType<'precise-volume'>;
|
|
||||||
|
|
||||||
export default (_options: ConfigType<'precise-volume'>) => {
|
|
||||||
options = _options;
|
|
||||||
document.addEventListener('apiLoaded', (e) => {
|
|
||||||
api = e.detail;
|
|
||||||
ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
|
|
||||||
ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
|
|
||||||
firstRun();
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Without this function it would rewrite config 20 time when volume change by 20
|
|
||||||
const writeOptions = debounce(() => {
|
|
||||||
setOptions('precise-volume', options);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
export const moveVolumeHud = debounce((showVideo: boolean) => {
|
|
||||||
const volumeHud = $<HTMLElement>('#volumeHud');
|
|
||||||
if (!volumeHud) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeHud.style.top = showVideo
|
|
||||||
? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
|
|
||||||
: '0';
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
|
|
||||||
volumeHud.style.opacity = '0';
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
const hideVolumeSlider = debounce((slider: HTMLElement) => {
|
|
||||||
slider.classList.remove('on-hover');
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
/** Restore saved volume and setup tooltip */
|
|
||||||
function firstRun() {
|
|
||||||
if (typeof options.savedVolume === 'number') {
|
|
||||||
// Set saved volume as tooltip
|
|
||||||
setTooltip(options.savedVolume);
|
|
||||||
|
|
||||||
if (api.getVolume() !== options.savedVolume) {
|
|
||||||
setVolume(options.savedVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPlaybar();
|
|
||||||
|
|
||||||
setupLocalArrowShortcuts();
|
|
||||||
|
|
||||||
// Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue
|
|
||||||
const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none';
|
|
||||||
injectVolumeHud(noVid);
|
|
||||||
if (!noVid) {
|
|
||||||
setupVideoPlayerOnwheel();
|
|
||||||
if (!isEnabled('video-toggle')) {
|
|
||||||
// Video-toggle handles hud positioning on its own
|
|
||||||
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
|
|
||||||
$('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change options from renderer to keep sync
|
|
||||||
ipcRenderer.on('setOptions', (_event, newOptions = {}) => {
|
|
||||||
Object.assign(options, newOptions);
|
|
||||||
setMenuOptions('precise-volume', options);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function injectVolumeHud(noVid: boolean) {
|
|
||||||
if (noVid) {
|
|
||||||
const position = 'top: 18px; right: 60px;';
|
|
||||||
const mainStyle = 'font-size: xx-large;';
|
|
||||||
|
|
||||||
$('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML(
|
|
||||||
'beforeend',
|
|
||||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const position = 'top: 10px; left: 10px;';
|
|
||||||
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;';
|
|
||||||
|
|
||||||
$('#song-video')?.insertAdjacentHTML(
|
|
||||||
'afterend',
|
|
||||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showVolumeHud(volume: number) {
|
|
||||||
const volumeHud = $<HTMLElement>('#volumeHud');
|
|
||||||
if (!volumeHud) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeHud.textContent = `${volume}%`;
|
|
||||||
volumeHud.style.opacity = '1';
|
|
||||||
|
|
||||||
hideVolumeHud(volumeHud);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add onwheel event to video player */
|
|
||||||
function setupVideoPlayerOnwheel() {
|
|
||||||
const panel = $<HTMLElement>('#main-panel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.addEventListener('wheel', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
// Event.deltaY < 0 means wheel-up
|
|
||||||
changeVolume(event.deltaY < 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveVolume(volume: number) {
|
|
||||||
options.savedVolume = volume;
|
|
||||||
writeOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add onwheel event to play bar and also track if play bar is hovered */
|
|
||||||
function setupPlaybar() {
|
|
||||||
const playerbar = $<HTMLElement>('ytmusic-player-bar');
|
|
||||||
if (!playerbar) return;
|
|
||||||
|
|
||||||
playerbar.addEventListener('wheel', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
// Event.deltaY < 0 means wheel-up
|
|
||||||
changeVolume(event.deltaY < 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep track of mouse position for showVolumeSlider()
|
|
||||||
playerbar.addEventListener('mouseenter', () => {
|
|
||||||
playerbar.classList.add('on-hover');
|
|
||||||
});
|
|
||||||
|
|
||||||
playerbar.addEventListener('mouseleave', () => {
|
|
||||||
playerbar.classList.remove('on-hover');
|
|
||||||
});
|
|
||||||
|
|
||||||
setupSliderObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save volume + Update the volume tooltip when volume-slider is manually changed */
|
|
||||||
function setupSliderObserver() {
|
|
||||||
const sliderObserver = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.target instanceof HTMLInputElement) {
|
|
||||||
// This checks that volume-slider was manually set
|
|
||||||
const target = mutation.target;
|
|
||||||
const targetValueNumeric = Number(target.value);
|
|
||||||
if (mutation.oldValue !== target.value
|
|
||||||
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) {
|
|
||||||
// Diff>4 means it was manually set
|
|
||||||
setTooltip(targetValueNumeric);
|
|
||||||
saveVolume(targetValueNumeric);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const slider = $('#volume-slider');
|
|
||||||
if (!slider) return;
|
|
||||||
|
|
||||||
// Observing only changes in 'value' of volume-slider
|
|
||||||
sliderObserver.observe(slider, {
|
|
||||||
attributeFilter: ['value'],
|
|
||||||
attributeOldValue: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolume(value: number) {
|
|
||||||
api.setVolume(value);
|
|
||||||
// Save the new volume
|
|
||||||
saveVolume(value);
|
|
||||||
|
|
||||||
// Change slider position (important)
|
|
||||||
updateVolumeSlider();
|
|
||||||
|
|
||||||
// Change tooltips to new value
|
|
||||||
setTooltip(value);
|
|
||||||
// Show volume slider
|
|
||||||
showVolumeSlider();
|
|
||||||
// Show volume HUD
|
|
||||||
showVolumeHud(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** If (toIncrease = false) then volume decrease */
|
|
||||||
function changeVolume(toIncrease: boolean) {
|
|
||||||
// Apply volume change if valid
|
|
||||||
const steps = Number(options.steps || 1);
|
|
||||||
setVolume(toIncrease
|
|
||||||
? Math.min(api.getVolume() + steps, 100)
|
|
||||||
: Math.max(api.getVolume() - steps, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVolumeSlider() {
|
|
||||||
const savedVolume = options.savedVolume ?? 0;
|
|
||||||
// Slider value automatically rounds to multiples of 5
|
|
||||||
for (const slider of ['#volume-slider', '#expand-volume-slider']) {
|
|
||||||
const silderElement = $<HTMLInputElement>(slider);
|
|
||||||
if (silderElement) {
|
|
||||||
silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showVolumeSlider() {
|
|
||||||
const slider = $<HTMLElement>('#volume-slider');
|
|
||||||
if (!slider) return;
|
|
||||||
|
|
||||||
// This class display the volume slider if not in minimized mode
|
|
||||||
slider.classList.add('on-hover');
|
|
||||||
|
|
||||||
hideVolumeSlider(slider);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small)
|
|
||||||
const tooltipTargets = [
|
|
||||||
'#volume-slider',
|
|
||||||
'tp-yt-paper-icon-button.volume',
|
|
||||||
'#expand-volume-slider',
|
|
||||||
'#expand-volume',
|
|
||||||
];
|
|
||||||
|
|
||||||
function setTooltip(volume: number) {
|
|
||||||
for (const target of tooltipTargets) {
|
|
||||||
const tooltipTargetElement = $<HTMLElement>(target);
|
|
||||||
if (tooltipTargetElement) {
|
|
||||||
tooltipTargetElement.title = `${volume}%`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupLocalArrowShortcuts() {
|
|
||||||
if (options.arrowsShortcut) {
|
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
if ($<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.code) {
|
|
||||||
case 'ArrowUp': {
|
|
||||||
event.preventDefault();
|
|
||||||
changeVolume(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ArrowDown': {
|
|
||||||
event.preventDefault();
|
|
||||||
changeVolume(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import { enabled } from './back';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
function changeOptions(changedOptions: Partial<ConfigType<'precise-volume'>>, options: ConfigType<'precise-volume'>, win: BrowserWindow) {
|
|
||||||
for (const option in changedOptions) {
|
|
||||||
// HACK: Weird TypeScript error
|
|
||||||
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
|
|
||||||
}
|
|
||||||
// Dynamically change setting if plugin is enabled
|
|
||||||
if (enabled()) {
|
|
||||||
win.webContents.send('setOptions', changedOptions);
|
|
||||||
} else { // Fallback to usual method if disabled
|
|
||||||
setMenuOptions('precise-volume', options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Local Arrowkeys Controls',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: Boolean(options.arrowsShortcut),
|
|
||||||
click(item) {
|
|
||||||
changeOptions({ arrowsShortcut: item.checked }, options, win);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Global Hotkeys',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: Boolean(options.globalShortcuts?.volumeUp ?? options.globalShortcuts?.volumeDown),
|
|
||||||
click: (item) => promptGlobalShortcuts(win, options, item),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Set Custom Volume Steps',
|
|
||||||
click: () => promptVolumeSteps(win, options),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper function for globalShortcuts prompt
|
|
||||||
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
|
|
||||||
|
|
||||||
async function promptVolumeSteps(win: BrowserWindow, options: ConfigType<'precise-volume'>) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Volume Steps',
|
|
||||||
label: 'Choose Volume Increase/Decrease Steps',
|
|
||||||
value: options.steps || 1,
|
|
||||||
type: 'counter',
|
|
||||||
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
|
|
||||||
width: 380,
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output || output === 0) { // 0 is somewhat valid
|
|
||||||
changeOptions({ steps: output }, options, win);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptGlobalShortcuts(win: BrowserWindow, options: ConfigType<'precise-volume'>, item: MenuItem) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Global Volume Keybinds',
|
|
||||||
label: 'Choose Global Volume Keybinds:',
|
|
||||||
type: 'keybind',
|
|
||||||
keybindOptions: [
|
|
||||||
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
|
|
||||||
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
|
|
||||||
],
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
|
||||||
const newGlobalShortcuts: {
|
|
||||||
volumeUp: string;
|
|
||||||
volumeDown: string;
|
|
||||||
} = { volumeUp: '', volumeDown: '' };
|
|
||||||
for (const { value, accelerator } of output) {
|
|
||||||
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
|
|
||||||
|
|
||||||
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
|
|
||||||
} else {
|
|
||||||
// Reset checkbox if prompt was canceled
|
|
||||||
item.checked = !item.checked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
/* what */
|
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
||||||
|
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
const ignored = {
|
|
||||||
id: ['volume-slider', 'expand-volume-slider'],
|
|
||||||
types: ['mousewheel', 'keydown', 'keyup'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function overrideAddEventListener() {
|
|
||||||
// YO WHAT ARE YOU DOING NOW?!?!
|
|
||||||
// Save native addEventListener
|
|
||||||
// @ts-ignore
|
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
||||||
Element.prototype._addEventListener = Element.prototype.addEventListener;
|
|
||||||
// Override addEventListener to Ignore specific events in volume-slider
|
|
||||||
Element.prototype.addEventListener = function (type: string, listener: (event: Event) => void, useCapture = false) {
|
|
||||||
if (!(
|
|
||||||
ignored.id.includes(this.id)
|
|
||||||
&& ignored.types.includes(type)
|
|
||||||
)) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
|
|
||||||
(this as any)._addEventListener(type, listener, useCapture);
|
|
||||||
} else if (is.dev()) {
|
|
||||||
console.log(`Ignoring event: "${this.id}.${type}()"`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
overrideAddEventListener();
|
|
||||||
// Restore original function after finished loading to avoid keeping Element.prototype altered
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
|
||||||
Element.prototype.addEventListener = (Element.prototype as any)._addEventListener;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
|
||||||
(Element.prototype as any)._addEventListener = undefined;
|
|
||||||
|
|
||||||
}, { once: true });
|
|
||||||
};
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { ipcMain, dialog } from 'electron';
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox({
|
|
||||||
type: 'question',
|
|
||||||
buttons: qualityLabels,
|
|
||||||
defaultId: currentIndex,
|
|
||||||
title: 'Choose Video Quality',
|
|
||||||
message: 'Choose Video Quality:',
|
|
||||||
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
|
|
||||||
cancelId: -1,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
import { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
function $(selector: string): HTMLElement | null {
|
|
||||||
return document.querySelector(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
|
|
||||||
|
|
||||||
function setup(event: CustomEvent<YoutubePlayer>) {
|
|
||||||
const api = event.detail;
|
|
||||||
|
|
||||||
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
|
|
||||||
|
|
||||||
qualitySettingsButton.addEventListener('click', function chooseQuality() {
|
|
||||||
setTimeout(() => $('#player')?.click());
|
|
||||||
|
|
||||||
const qualityLevels = api.getAvailableQualityLevels();
|
|
||||||
|
|
||||||
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
|
||||||
|
|
||||||
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => {
|
|
||||||
if (promise.response === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newQuality = qualityLevels[promise.response];
|
|
||||||
api.setPlaybackQualityRange(newQuality);
|
|
||||||
api.setPlaybackQuality(newQuality);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { BrowserWindow, globalShortcut } from 'electron';
|
|
||||||
import is from 'electron-is';
|
|
||||||
import electronLocalshortcut from 'electron-localshortcut';
|
|
||||||
|
|
||||||
import registerMPRIS from './mpris';
|
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
|
||||||
globalShortcut.register(shortcut, () => {
|
|
||||||
action(webContents);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
|
||||||
electronLocalshortcut.register(win, shortcut, () => {
|
|
||||||
action(win.webContents);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) {
|
|
||||||
const songControls = getSongControls(win);
|
|
||||||
const { playPause, next, previous, search } = songControls;
|
|
||||||
|
|
||||||
if (options.overrideMediaKeys) {
|
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
|
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
|
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
_registerLocalShortcut(win, 'CommandOrControl+F', search);
|
|
||||||
_registerLocalShortcut(win, 'CommandOrControl+L', search);
|
|
||||||
|
|
||||||
if (is.linux()) {
|
|
||||||
registerMPRIS(win);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { global, local } = options;
|
|
||||||
const shortcutOptions = { global, local };
|
|
||||||
|
|
||||||
for (const optionType in shortcutOptions) {
|
|
||||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerAllShortcuts(container: Record<string, string>, type: string) {
|
|
||||||
for (const action in container) {
|
|
||||||
if (!container[action]) {
|
|
||||||
continue; // Action accelerator is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
|
||||||
const actionCallback: () => void = songControls[action as keyof typeof songControls];
|
|
||||||
if (typeof actionCallback !== 'function') {
|
|
||||||
console.warn('Invalid action', action);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'global') {
|
|
||||||
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
|
|
||||||
} else { // Type === "local"
|
|
||||||
_registerLocalShortcut(win, local[action], actionCallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default registerShortcuts;
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'shortcuts'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Set Global Song Controls',
|
|
||||||
click: () => promptKeybind(options, win),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Override MediaKeys',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.overrideMediaKeys,
|
|
||||||
click: (item) => setOption(options, 'overrideMediaKeys', item.checked),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function setOption<Key extends keyof ConfigType<'shortcuts'> = keyof ConfigType<'shortcuts'>>(
|
|
||||||
options: ConfigType<'shortcuts'>,
|
|
||||||
key: Key | null = null,
|
|
||||||
newValue: ConfigType<'shortcuts'>[Key] | null = null,
|
|
||||||
) {
|
|
||||||
if (key && newValue !== null) {
|
|
||||||
options[key] = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMenuOptions('shortcuts', options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function for keybind prompt
|
|
||||||
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ value: value_, label: label_, default: default_ });
|
|
||||||
|
|
||||||
async function promptKeybind(options: ConfigType<'shortcuts'>, win: BrowserWindow) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Global Keybinds',
|
|
||||||
label: 'Choose Global Keybinds for Songs Control:',
|
|
||||||
type: 'keybind',
|
|
||||||
keybindOptions: [ // If default=undefined then no default is used
|
|
||||||
kb('Previous', 'previous', options.global?.previous),
|
|
||||||
kb('Play / Pause', 'playPause', options.global?.playPause),
|
|
||||||
kb('Next', 'next', options.global?.next),
|
|
||||||
],
|
|
||||||
height: 270,
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
|
||||||
if (!options.global) {
|
|
||||||
options.global = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { value, accelerator } of output) {
|
|
||||||
options.global[value] = accelerator;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOption(options);
|
|
||||||
}
|
|
||||||
// Else -> pressed cancel
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
type SkipSilencesOptions = ConfigType<'skip-silences'>;
|
|
||||||
|
|
||||||
export default (options: SkipSilencesOptions) => {
|
|
||||||
let isSilent = false;
|
|
||||||
let hasAudioStarted = false;
|
|
||||||
|
|
||||||
const smoothing = 0.1;
|
|
||||||
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
|
|
||||||
const interval = 2; // Ms
|
|
||||||
const history = 10;
|
|
||||||
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
'audioCanPlay',
|
|
||||||
(e) => {
|
|
||||||
const video = document.querySelector('video');
|
|
||||||
const { audioContext } = e.detail;
|
|
||||||
const sourceNode = e.detail.audioSource;
|
|
||||||
|
|
||||||
// Use an audio analyser similar to Hark
|
|
||||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
|
||||||
const analyser = audioContext.createAnalyser();
|
|
||||||
analyser.fftSize = 512;
|
|
||||||
analyser.smoothingTimeConstant = smoothing;
|
|
||||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
|
||||||
|
|
||||||
sourceNode.connect(analyser);
|
|
||||||
analyser.connect(audioContext.destination);
|
|
||||||
|
|
||||||
const looper = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
|
||||||
|
|
||||||
let history = 0;
|
|
||||||
if (currentVolume > threshold && isSilent) {
|
|
||||||
// Trigger quickly, short history
|
|
||||||
for (
|
|
||||||
let i = speakingHistory.length - 3;
|
|
||||||
i < speakingHistory.length;
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
history += speakingHistory[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history >= 2) {
|
|
||||||
// Not silent
|
|
||||||
isSilent = false;
|
|
||||||
hasAudioStarted = true;
|
|
||||||
}
|
|
||||||
} else if (currentVolume < threshold && !isSilent) {
|
|
||||||
for (const element of speakingHistory) {
|
|
||||||
history += element;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history == 0 // Silent
|
|
||||||
|
|
||||||
&& !(
|
|
||||||
video && (
|
|
||||||
video.paused
|
|
||||||
|| video.seeking
|
|
||||||
|| video.ended
|
|
||||||
|| video.muted
|
|
||||||
|| video.volume === 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
isSilent = true;
|
|
||||||
skipSilence();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
speakingHistory.shift();
|
|
||||||
speakingHistory.push(Number(currentVolume > threshold));
|
|
||||||
|
|
||||||
looper();
|
|
||||||
}, interval);
|
|
||||||
};
|
|
||||||
|
|
||||||
looper();
|
|
||||||
|
|
||||||
const skipSilence = () => {
|
|
||||||
if (options.onlySkipBeginning && hasAudioStarted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSilent && video && !video.paused) {
|
|
||||||
video.currentTime += 0.2; // In s
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video?.addEventListener('play', () => {
|
|
||||||
hasAudioStarted = false;
|
|
||||||
skipSilence();
|
|
||||||
});
|
|
||||||
|
|
||||||
video?.addEventListener('seeked', () => {
|
|
||||||
hasAudioStarted = false;
|
|
||||||
skipSilence();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
passive: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) {
|
|
||||||
let maxVolume = Number.NEGATIVE_INFINITY;
|
|
||||||
analyser.getFloatFrequencyData(fftBins);
|
|
||||||
|
|
||||||
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
|
||||||
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
|
||||||
maxVolume = fftBins[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxVolume;
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron';
|
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import { sortSegments } from './segments';
|
|
||||||
|
|
||||||
import { SkipSegment } from './types';
|
|
||||||
|
|
||||||
import defaultConfig from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
|
|
||||||
const { apiURL, categories } = {
|
|
||||||
...defaultConfig.plugins.sponsorblock,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => {
|
|
||||||
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
|
|
||||||
win.webContents.send('sponsorblock-skip', segments);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
|
|
||||||
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
|
|
||||||
categories,
|
|
||||||
)}`;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(sponsorBlockURL, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
redirect: 'follow',
|
|
||||||
});
|
|
||||||
if (resp.status !== 200) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = await resp.json() as SkipSegment[];
|
|
||||||
return sortSegments(
|
|
||||||
segments.map((submission) => submission.segment),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log('error on sponsorblock request:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import { Segment } from './types';
|
|
||||||
|
|
||||||
let currentSegments: Segment[] = [];
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
|
|
||||||
currentSegments = segments;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
|
||||||
const video = document.querySelector<HTMLVideoElement>('video');
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
video.addEventListener('timeupdate', (e) => {
|
|
||||||
if (e.target instanceof HTMLVideoElement) {
|
|
||||||
const target = e.target;
|
|
||||||
|
|
||||||
for (const segment of currentSegments) {
|
|
||||||
if (
|
|
||||||
target.currentTime >= segment[0]
|
|
||||||
&& target.currentTime < segment[1]
|
|
||||||
) {
|
|
||||||
target.currentTime = segment[1];
|
|
||||||
if (is.dev()) {
|
|
||||||
console.log('SponsorBlock: skipping segment', segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Reset segments on song end
|
|
||||||
video.addEventListener('emptied', () => currentSegments = []);
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
export type Segment = [number, number];
|
|
||||||
|
|
||||||
export interface SkipSegment { // Array of this object
|
|
||||||
segment: Segment; //[0, 15.23] start and end time in seconds
|
|
||||||
UUID: string,
|
|
||||||
category: string, // [1]
|
|
||||||
videoDuration: number // Duration of video when submission occurred (to be used to determine when a submission is out of date). 0 when unknown. +- 1 second
|
|
||||||
actionType: string, // [3]
|
|
||||||
locked: number, // if submission is locked
|
|
||||||
votes: number, // Votes on segment
|
|
||||||
description: string, // title for chapters, empty string for other segments
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { BrowserWindow, nativeImage } from 'electron';
|
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
|
||||||
import { getMediaIconLocation } from '../utils';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
let currentSongInfo: SongInfo;
|
|
||||||
|
|
||||||
const { playPause, next, previous } = getSongControls(win);
|
|
||||||
|
|
||||||
const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => {
|
|
||||||
// Wait for song to start before setting thumbar
|
|
||||||
if (!songInfo?.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Win32 require full rewrite of components
|
|
||||||
win.setThumbarButtons([
|
|
||||||
{
|
|
||||||
tooltip: 'Previous',
|
|
||||||
icon: nativeImage.createFromPath(get('previous')),
|
|
||||||
click() {
|
|
||||||
previous();
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
tooltip: 'Play/Pause',
|
|
||||||
// Update icon based on play state
|
|
||||||
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
|
|
||||||
click() {
|
|
||||||
playPause();
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
tooltip: 'Next',
|
|
||||||
icon: nativeImage.createFromPath(get('next')),
|
|
||||||
click() {
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Util
|
|
||||||
const get = (kind: string) => {
|
|
||||||
return path.join(getMediaIconLocation(), `${kind}.png`);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Update currentsonginfo for win.on('show')
|
|
||||||
currentSongInfo = songInfo;
|
|
||||||
// Update thumbar
|
|
||||||
setThumbar(win, songInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Need to set thumbar again after win.show
|
|
||||||
win.on('show', () => {
|
|
||||||
setThumbar(win, currentSongInfo);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import { TouchBar, NativeImage, BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import registerCallback from '../../providers/song-info';
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
const {
|
|
||||||
TouchBarButton,
|
|
||||||
TouchBarLabel,
|
|
||||||
TouchBarSpacer,
|
|
||||||
TouchBarSegmentedControl,
|
|
||||||
TouchBarScrubber,
|
|
||||||
} = TouchBar;
|
|
||||||
|
|
||||||
// Songtitle label
|
|
||||||
const songTitle = new TouchBarLabel({
|
|
||||||
label: '',
|
|
||||||
});
|
|
||||||
// This will store the song controls once available
|
|
||||||
let controls: (() => void)[] = [];
|
|
||||||
|
|
||||||
// This will store the song image once available
|
|
||||||
const songImage: {
|
|
||||||
icon?: NativeImage;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
// Pause/play button
|
|
||||||
const pausePlayButton = new TouchBarButton({});
|
|
||||||
|
|
||||||
// The song control buttons (control functions are in the same order)
|
|
||||||
const buttons = new TouchBarSegmentedControl({
|
|
||||||
mode: 'buttons',
|
|
||||||
segments: [
|
|
||||||
new TouchBarButton({
|
|
||||||
label: '⏮',
|
|
||||||
}),
|
|
||||||
pausePlayButton,
|
|
||||||
new TouchBarButton({
|
|
||||||
label: '⏭',
|
|
||||||
}),
|
|
||||||
new TouchBarButton({
|
|
||||||
label: '👎',
|
|
||||||
}),
|
|
||||||
new TouchBarButton({
|
|
||||||
label: '👍',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
change: (i) => controls[i](),
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is the touchbar object, this combines everything with proper layout
|
|
||||||
const touchBar = new TouchBar({
|
|
||||||
items: [
|
|
||||||
new TouchBarScrubber({
|
|
||||||
items: [songImage, songTitle],
|
|
||||||
continuous: false,
|
|
||||||
}),
|
|
||||||
new TouchBarSpacer({
|
|
||||||
size: 'flexible',
|
|
||||||
}),
|
|
||||||
buttons,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { playPause, next, previous, dislike, like } = getSongControls(win);
|
|
||||||
|
|
||||||
// If the page is ready, register the callback
|
|
||||||
win.once('ready-to-show', () => {
|
|
||||||
controls = [previous, playPause, next, dislike, like];
|
|
||||||
|
|
||||||
// Register the callback
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Song information changed, so lets update the touchBar
|
|
||||||
|
|
||||||
// Set the song title
|
|
||||||
songTitle.label = songInfo.title;
|
|
||||||
|
|
||||||
// Changes the pause button if paused
|
|
||||||
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
|
|
||||||
|
|
||||||
// Get image source
|
|
||||||
songImage.icon = songInfo.image
|
|
||||||
? songInfo.image.resize({ height: 23 })
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
win.setTouchBar(touchBar);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { ipcMain, net, BrowserWindow } from 'electron';
|
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import registerCallback from '../../providers/song-info';
|
|
||||||
|
|
||||||
const secToMilisec = (t: number) => Math.round(Number(t) * 1e3);
|
|
||||||
|
|
||||||
interface Data {
|
|
||||||
album: string | null | undefined;
|
|
||||||
album_url: string;
|
|
||||||
artists: string[];
|
|
||||||
cover: string;
|
|
||||||
cover_url: string;
|
|
||||||
duration: number;
|
|
||||||
progress: number;
|
|
||||||
status: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: Data = {
|
|
||||||
cover: '',
|
|
||||||
cover_url: '',
|
|
||||||
title: '',
|
|
||||||
artists: [] as string[],
|
|
||||||
status: '',
|
|
||||||
progress: 0,
|
|
||||||
duration: 0,
|
|
||||||
album_url: '',
|
|
||||||
album: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const post = (data: Data) => {
|
|
||||||
const port = 1608;
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Access-Control-Allow-Headers': '*',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
};
|
|
||||||
const url = `http://127.0.0.1:${port}/`;
|
|
||||||
net.fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ data }),
|
|
||||||
}).catch((error: { code: number, errno: number }) => {
|
|
||||||
if (is.dev()) {
|
|
||||||
console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
|
|
||||||
ipcMain.on('timeChanged', (_, t: number) => {
|
|
||||||
if (!data.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.progress = secToMilisec(t);
|
|
||||||
post(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
if (!songInfo.title && !songInfo.artist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.duration = secToMilisec(songInfo.songDuration);
|
|
||||||
data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
|
|
||||||
data.cover = songInfo.imageSrc ?? '';
|
|
||||||
data.cover_url = songInfo.imageSrc ?? '';
|
|
||||||
data.album_url = songInfo.imageSrc ?? '';
|
|
||||||
data.title = songInfo.title;
|
|
||||||
data.artists = [songInfo.artist];
|
|
||||||
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
|
||||||
data.album = songInfo.album;
|
|
||||||
post(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
102
plugins/utils.ts
102
plugins/utils.ts
@ -1,102 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { app, ipcMain, ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import { ValueOf } from '../utils/type-utils';
|
|
||||||
import defaultConfig from '../config/defaults';
|
|
||||||
|
|
||||||
export const getAssetsDirectoryLocation = () => path.resolve(__dirname, 'assets');
|
|
||||||
|
|
||||||
export const getMediaIconLocation = () =>
|
|
||||||
app.isPackaged
|
|
||||||
? path.resolve(app.getPath('userData'), 'icons')
|
|
||||||
: path.resolve(getAssetsDirectoryLocation(), 'media-icons-black');
|
|
||||||
|
|
||||||
// Creates a DOM element from an HTML string
|
|
||||||
export const ElementFromHtml = (html: string): HTMLElement => {
|
|
||||||
const template = document.createElement('template');
|
|
||||||
html = html.trim(); // Never return a text node of whitespace as the result
|
|
||||||
template.innerHTML = html;
|
|
||||||
|
|
||||||
return template.content.firstElementChild as HTMLElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Creates a DOM element from a HTML file
|
|
||||||
export const ElementFromFile = (filepath: fs.PathOrFileDescriptor) => ElementFromHtml(fs.readFileSync(filepath, 'utf8'));
|
|
||||||
|
|
||||||
export const templatePath = (pluginPath: string, name: string) => path.join(pluginPath, 'templates', name);
|
|
||||||
|
|
||||||
export const Actions = {
|
|
||||||
NEXT: 'next',
|
|
||||||
BACK: 'back',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const triggerAction = <Parameters extends unknown[]>(channel: string, action: ValueOf<typeof Actions>, ...args: Parameters) => ipcRenderer.send(channel, action, ...args);
|
|
||||||
|
|
||||||
export const triggerActionSync = <Parameters extends unknown[]>(channel: string, action: ValueOf<typeof Actions>, ...args: Parameters): unknown => ipcRenderer.sendSync(channel, action, ...args);
|
|
||||||
|
|
||||||
export const listenAction = (channel: string, callback: (event: Electron.IpcMainEvent, action: string) => void) => ipcMain.on(channel, callback);
|
|
||||||
|
|
||||||
export const fileExists = (
|
|
||||||
path: fs.PathLike,
|
|
||||||
callbackIfExists: { (): void; (): void; (): void; },
|
|
||||||
callbackIfError: (() => void) | undefined = undefined,
|
|
||||||
) => {
|
|
||||||
fs.access(path, fs.constants.F_OK, (error) => {
|
|
||||||
if (error) {
|
|
||||||
callbackIfError?.();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callbackIfExists();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cssToInject = new Map<string, (() => void) | undefined>();
|
|
||||||
const cssToInjectFile = new Map<string, (() => void) | undefined>();
|
|
||||||
export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => {
|
|
||||||
if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
|
|
||||||
setupCssInjection(webContents);
|
|
||||||
}
|
|
||||||
|
|
||||||
cssToInject.set(css, cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string, cb: (() => void) | undefined = undefined) => {
|
|
||||||
if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
|
|
||||||
setupCssInjection(webContents);
|
|
||||||
}
|
|
||||||
|
|
||||||
cssToInjectFile.set(filepath, cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupCssInjection = (webContents: Electron.WebContents) => {
|
|
||||||
webContents.on('did-finish-load', () => {
|
|
||||||
cssToInject.forEach(async (callback, css) => {
|
|
||||||
await webContents.insertCSS(css);
|
|
||||||
callback?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
cssToInjectFile.forEach(async (callback, filepath) => {
|
|
||||||
await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
|
|
||||||
callback?.();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAvailablePluginNames = () => {
|
|
||||||
return Object.keys(defaultConfig.plugins).filter((name) => {
|
|
||||||
if (is.windows() && name === 'touchbar') {
|
|
||||||
return false;
|
|
||||||
} else if (is.macOS() && name === 'taskbar-mediacontrol') {
|
|
||||||
return false;
|
|
||||||
} else if (is.linux() && (name === 'taskbar-mediacontrol' || name === 'touchbar')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import forceHideStyle from './force-hide.css';
|
|
||||||
import buttonSwitcherStyle from './button-switcher.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => {
|
|
||||||
if (options.forceHide) {
|
|
||||||
injectCSS(win.webContents, forceHideStyle);
|
|
||||||
} else if (!options.mode || options.mode === 'custom') {
|
|
||||||
injectCSS(win.webContents, buttonSwitcherStyle);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
import buttonTemplate from './templates/button_template.html';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
import { setOptions, isEnabled } from '../../config/plugins';
|
|
||||||
|
|
||||||
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front';
|
|
||||||
|
|
||||||
import { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
import { ThumbnailElement } from '../../types/get-player-response';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const moveVolumeHud = isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {};
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string): E | null {
|
|
||||||
return document.querySelector<E>(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
let options: ConfigType<'video-toggle'>;
|
|
||||||
let player: HTMLElement & { videoMode_: boolean } | null;
|
|
||||||
let video: HTMLVideoElement | null;
|
|
||||||
let api: YoutubePlayer;
|
|
||||||
|
|
||||||
const switchButtonDiv = ElementFromHtml(buttonTemplate);
|
|
||||||
|
|
||||||
export default (_options: ConfigType<'video-toggle'>) => {
|
|
||||||
if (_options.forceHide) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (_options.mode) {
|
|
||||||
case 'native': {
|
|
||||||
$('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
|
|
||||||
$('ytmusic-player')?.setAttribute('has-av-switcher', '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'disabled': {
|
|
||||||
$('ytmusic-player-page')?.removeAttribute('has-av-switcher');
|
|
||||||
$('ytmusic-player')?.removeAttribute('has-av-switcher');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
case 'custom': {
|
|
||||||
options = _options;
|
|
||||||
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup(e: CustomEvent<YoutubePlayer>) {
|
|
||||||
api = e.detail;
|
|
||||||
player = $<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player');
|
|
||||||
video = $<HTMLVideoElement>('video');
|
|
||||||
|
|
||||||
$<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
|
|
||||||
|
|
||||||
setVideoState(!options.hideVideo);
|
|
||||||
forcePlaybackMode();
|
|
||||||
// Fix black video
|
|
||||||
if (video) {
|
|
||||||
video.style.height = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
//Prevents bubbling to the player which causes it to stop or resume
|
|
||||||
switchButtonDiv.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Button checked = show video
|
|
||||||
switchButtonDiv.addEventListener('change', (e) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
|
|
||||||
setVideoState(target.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
video?.addEventListener('srcChanged', videoStarted);
|
|
||||||
|
|
||||||
observeThumbnail();
|
|
||||||
|
|
||||||
switch (options.align) {
|
|
||||||
case 'right': {
|
|
||||||
switchButtonDiv.style.left = 'calc(100% - 240px)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'middle': {
|
|
||||||
switchButtonDiv.style.left = 'calc(50% - 120px)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
case 'left': {
|
|
||||||
switchButtonDiv.style.left = '0px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVideoState(showVideo: boolean) {
|
|
||||||
options.hideVideo = !showVideo;
|
|
||||||
setOptions('video-toggle', options);
|
|
||||||
|
|
||||||
const checkbox = $<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
|
|
||||||
if (checkbox) checkbox.checked = !options.hideVideo;
|
|
||||||
|
|
||||||
if (player) {
|
|
||||||
player.style.margin = showVideo ? '' : 'auto 0px';
|
|
||||||
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
|
|
||||||
|
|
||||||
$<HTMLElement>('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
|
|
||||||
$<HTMLElement>('#song-image')!.style.display = showVideo ? 'none' : 'block';
|
|
||||||
|
|
||||||
if (showVideo && video && !video.style.top) {
|
|
||||||
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
moveVolumeHud(showVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoStarted() {
|
|
||||||
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
|
|
||||||
// Video doesn't exist -> switch to song mode
|
|
||||||
setVideoState(false);
|
|
||||||
// Hide toggle button
|
|
||||||
switchButtonDiv.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
const songImage = $<HTMLImageElement>('#song-image img');
|
|
||||||
if (!songImage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Switch to high-res thumbnail
|
|
||||||
forceThumbnail(songImage);
|
|
||||||
// Show toggle button
|
|
||||||
switchButtonDiv.style.display = 'initial';
|
|
||||||
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
|
|
||||||
if (!options.hideVideo && $<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
|
|
||||||
setVideoState(true);
|
|
||||||
} else {
|
|
||||||
moveVolumeHud(!options.hideVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
|
|
||||||
// this function fix the problem by overriding that override :)
|
|
||||||
function forcePlaybackMode() {
|
|
||||||
if (player) {
|
|
||||||
const playbackModeObserver = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.target instanceof HTMLElement) {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
|
|
||||||
playbackModeObserver.disconnect();
|
|
||||||
target.setAttribute('playback-mode', 'ATV_PREFERRED');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeThumbnail() {
|
|
||||||
const playbackModeObserver = new MutationObserver((mutations) => {
|
|
||||||
if (!player?.videoMode_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.target instanceof HTMLImageElement) {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (!target.src.startsWith('data:')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
forceThumbnail(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
playbackModeObserver.observe($('#song-image img')!, { attributeFilter: ['src'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceThumbnail(img: HTMLImageElement) {
|
|
||||||
const thumbnails: ThumbnailElement[] = ($('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
|
|
||||||
if (thumbnails && thumbnails.length > 0) {
|
|
||||||
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
|
|
||||||
if (typeof thumbnail === 'string') img.src = thumbnail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Mode',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Custom toggle',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.mode === 'custom',
|
|
||||||
click() {
|
|
||||||
options.mode = 'custom';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Native toggle',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.mode === 'native',
|
|
||||||
click() {
|
|
||||||
options.mode = 'native';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Disabled',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.mode === 'disabled',
|
|
||||||
click() {
|
|
||||||
options.mode = 'disabled';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alignment',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Left',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.align === 'left',
|
|
||||||
click() {
|
|
||||||
options.align = 'left';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Middle',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.align === 'middle',
|
|
||||||
click() {
|
|
||||||
options.align = 'middle';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Right',
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.align === 'right',
|
|
||||||
click() {
|
|
||||||
options.align = 'right';
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Force Remove Video Tab',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.forceHide,
|
|
||||||
click(item) {
|
|
||||||
options.forceHide = item.checked;
|
|
||||||
setMenuOptions('video-toggle', options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<div class="video-switch-button">
|
|
||||||
<input checked="true" class="video-switch-button-checkbox" type="checkbox"></input>
|
|
||||||
<label class="video-switch-button-label" for=""><span class="video-switch-button-label-span">Song</span></label>
|
|
||||||
</div>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import emptyPlayerStyle from './empty-player.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, emptyPlayerStyle);
|
|
||||||
};
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers';
|
|
||||||
import { Visualizer } from './visualizers/visualizer';
|
|
||||||
|
|
||||||
import defaultConfig from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (options: ConfigType<'visualizer'>) => {
|
|
||||||
const optionsWithDefaults = {
|
|
||||||
...defaultConfig.plugins.visualizer,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
|
|
||||||
|
|
||||||
if (optionsWithDefaults.type === 'wave') {
|
|
||||||
visualizerType = wave;
|
|
||||||
} else if (optionsWithDefaults.type === 'butterchurn') {
|
|
||||||
visualizerType = butterchurn;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
'audioCanPlay',
|
|
||||||
(e) => {
|
|
||||||
const video = document.querySelector<HTMLVideoElement & { captureStream(): MediaStream; }>('video');
|
|
||||||
if (!video) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visualizerContainer = document.querySelector<HTMLElement>('#player');
|
|
||||||
if (!visualizerContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas = document.querySelector<HTMLCanvasElement>('#visualizer');
|
|
||||||
if (!canvas) {
|
|
||||||
canvas = document.createElement('canvas');
|
|
||||||
canvas.id = 'visualizer';
|
|
||||||
visualizerContainer?.prepend(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeCanvas = () => {
|
|
||||||
if (canvas) {
|
|
||||||
canvas.width = visualizerContainer.clientWidth;
|
|
||||||
canvas.height = visualizerContainer.clientHeight;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
resizeCanvas();
|
|
||||||
|
|
||||||
const gainNode = e.detail.audioContext.createGain();
|
|
||||||
gainNode.gain.value = 1.25;
|
|
||||||
e.detail.audioSource.connect(gainNode);
|
|
||||||
|
|
||||||
const visualizer = new visualizerType(
|
|
||||||
e.detail.audioContext,
|
|
||||||
e.detail.audioSource,
|
|
||||||
visualizerContainer,
|
|
||||||
canvas,
|
|
||||||
gainNode,
|
|
||||||
video.captureStream(),
|
|
||||||
optionsWithDefaults,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resizeVisualizer = (width: number, height: number) => {
|
|
||||||
resizeCanvas();
|
|
||||||
visualizer.resize(width, height);
|
|
||||||
};
|
|
||||||
|
|
||||||
resizeVisualizer(canvas.width, canvas.height);
|
|
||||||
const visualizerContainerObserver = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
visualizerContainerObserver.observe(visualizerContainer);
|
|
||||||
|
|
||||||
visualizer.render();
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const visualizerTypes = ['butterchurn', 'vudio', 'wave']; // For bundling
|
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Type',
|
|
||||||
submenu: visualizerTypes.map((visualizerType) => ({
|
|
||||||
label: visualizerType,
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.type === visualizerType,
|
|
||||||
click() {
|
|
||||||
options.type = visualizerType;
|
|
||||||
setMenuOptions('visualizer', options);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import type { ConfigType } from '../../../config/dynamic';
|
|
||||||
|
|
||||||
export abstract class Visualizer<T> {
|
|
||||||
/**
|
|
||||||
* The name must be the same as the file name.
|
|
||||||
*/
|
|
||||||
abstract name: string;
|
|
||||||
abstract visualizer: T;
|
|
||||||
|
|
||||||
protected constructor(
|
|
||||||
audioContext: AudioContext,
|
|
||||||
audioSource: MediaElementAudioSourceNode,
|
|
||||||
visualizerContainer: HTMLElement,
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
audioNode: GainNode,
|
|
||||||
stream: MediaStream,
|
|
||||||
options: ConfigType<'visualizer'>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
abstract resize(width: number, height: number): void;
|
|
||||||
abstract render(): void;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user