mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
Compare commits
700 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f44b6f0c33 | |||
| c45e4e50fc | |||
| 9839a973f7 | |||
| 9ea967f03b | |||
| 9d6765125b | |||
| 8d66735585 | |||
| 14b4c55ce7 | |||
| 1d1f4bbcc3 | |||
| bd520c7eff | |||
| 73e201bb2c | |||
| 81b08917ae | |||
| 81c2ab34d9 | |||
| 33faa2deb3 | |||
| 4d4ac56486 | |||
| 56ac2b3b06 | |||
| c72ea4bad5 | |||
| d60069555e | |||
| ed7025b4a2 | |||
| 5fbc0f8122 | |||
| 02a989ca07 | |||
| 7c6fe6748e | |||
| 8f2ed3039a | |||
| baeebd1959 | |||
| 49edbf723f | |||
| 3764ce9a7c | |||
| 46943520bd | |||
| 1048b3f99a | |||
| b1e40271e6 | |||
| 11429978c9 | |||
| 47ca6e0b1f | |||
| a273f6f73c | |||
| c8ba85be76 | |||
| 6633243628 | |||
| 2fb47933ac | |||
| 4fd683ed23 | |||
| e1e9748002 | |||
| dd122666c5 | |||
| 5483f0ee36 | |||
| 2c6c80d829 | |||
| 584d3e83c6 | |||
| 58d5256dd2 | |||
| 920d61a1c6 | |||
| c5c2d5b74c | |||
| 2daee01ff7 | |||
| d13c9b7ca6 | |||
| 5296a88525 | |||
| 8ce4b5b297 | |||
| 2c39c0efed | |||
| e917abaec9 | |||
| bdd0a2e8db | |||
| 362003e10e | |||
| 4e4b557413 | |||
| 3a068af925 | |||
| 44ca812330 | |||
| 74a69e1c7a | |||
| c3ef16c3dd | |||
| 8c5ac17cdf | |||
| c99b95e611 | |||
| 4362101c0a | |||
| 7ba205cc6c | |||
| abc1712cf7 | |||
| 92452f804f | |||
| c76df84ce3 | |||
| 185ebbf417 | |||
| 6726e2600b | |||
| 93e0664f95 | |||
| bf45ed10aa | |||
| 8da78d50c4 | |||
| b27a959c2b | |||
| cfe719b6bd | |||
| 071799c435 | |||
| 87ee7ed83d | |||
| 08fdd07969 | |||
| 02d5b78f55 | |||
| 5492afe5f6 | |||
| 9a7baeac23 | |||
| ccfe7434bf | |||
| 6dbed73e6b | |||
| 895136af0a | |||
| 72b4398024 | |||
| 65ce62adc1 | |||
| eafdd5046d | |||
| bbece751c0 | |||
| 719c244e32 | |||
| e70b41b256 | |||
| f4b6fd53f3 | |||
| f40ed04899 | |||
| c897323be0 | |||
| d7c4716a6e | |||
| 461cac741b | |||
| cee2e066b9 | |||
| 953d6fe3e4 | |||
| c592a26e42 | |||
| 62bacf76d0 | |||
| fc254db010 | |||
| 0287b69424 | |||
| ceebd99927 | |||
| 41285ac9fc | |||
| 7b9415033f | |||
| b1ffd93bc2 | |||
| 64637a6ac5 | |||
| b83afa22d9 | |||
| c68daabeab | |||
| 9ec0830a65 | |||
| 48943ee74b | |||
| 29d5b3c7db | |||
| 3de574a2e4 | |||
| e765d18ab0 | |||
| 4629759eec | |||
| a14d27da70 | |||
| 5256ffcf77 | |||
| e2bbc6abbc | |||
| 6243e6fd48 | |||
| 1434849142 | |||
| 9bd089adb0 | |||
| 6c1a4c0ac2 | |||
| d35fef82fe | |||
| bca22d8e24 | |||
| 754eac6ee0 | |||
| 762566cce6 | |||
| 286bc0113e | |||
| 62e8e673eb | |||
| 68996809f0 | |||
| 48a2a13163 | |||
| 6e94422b15 | |||
| 713e005aa8 | |||
| 3f3ab766ce | |||
| 00e1bbf994 | |||
| b45adac847 | |||
| 3d9b495863 | |||
| a70364facf | |||
| 02cb39602f | |||
| 12c31725fe | |||
| e0841060df | |||
| b7b55b5c83 | |||
| 43a9093eb7 | |||
| 67e43bc0e3 | |||
| a47c906ab2 | |||
| 41a01ba58a | |||
| ca2bd011e2 | |||
| 2106914aff | |||
| 1c11ddbb7d | |||
| fc1211f7a1 | |||
| 18f87c7b0d | |||
| f9a4bffa55 | |||
| 02f4aabead | |||
| f97dade168 | |||
| 8c3a6472f8 | |||
| 315f9783f5 | |||
| ada78837ce | |||
| 58c6a12e53 | |||
| 005c930d58 | |||
| 7c2891b732 | |||
| 8b36139dab | |||
| a045d65e58 | |||
| 7b064c1e6f | |||
| 6a2e3ab6c1 | |||
| 362da8c308 | |||
| 5658765f54 | |||
| 38449f003a | |||
| df75e480a6 | |||
| 79a95f133b | |||
| 9b1a5b8d26 | |||
| bb2e1bd616 | |||
| 2d518abc19 | |||
| 978aca1f9a | |||
| 2224786478 | |||
| 51364b63e7 | |||
| c897bedd90 | |||
| 831b1ea8e1 | |||
| 4d4dacbc71 | |||
| 1cd4f53657 | |||
| 2eda0e4948 | |||
| f2e04f9170 | |||
| c92b3915d9 | |||
| 6118a17b08 | |||
| 1490c0f179 | |||
| fdf203e70a | |||
| 8114a28964 | |||
| 663507b3f8 | |||
| 79d0c7b666 | |||
| ce4580605d | |||
| dda18a72af | |||
| f7a1de05c8 | |||
| 361606427a | |||
| 81fb5118aa | |||
| a76f12c01c | |||
| 88ee0fb989 | |||
| 3ec49bca74 | |||
| 1908921ae6 | |||
| b9dbd8bd4d | |||
| 587818b91e | |||
| 157ae05f80 | |||
| f2039e29e7 | |||
| ea2d33c3cf | |||
| d775e3d588 | |||
| e7ec15e90f | |||
| 403470be69 | |||
| 6dc0ba74c4 | |||
| 6dcfb336c2 | |||
| 84516b2ac1 | |||
| 57cf2a8cdd | |||
| e3ae97fec4 | |||
| ee76e2cb45 | |||
| de01bb6e75 | |||
| 42668c3e99 | |||
| 05f3c56e47 | |||
| d54977b9ee | |||
| b89fb4dc2f | |||
| a0cf77edfb | |||
| 069f9855d1 | |||
| e3e0775401 | |||
| d255e5ffe1 | |||
| fea460a374 | |||
| 302d3f693f | |||
| 9cc320d74b | |||
| e255777283 | |||
| ef66612cc8 | |||
| 4bed835347 | |||
| b5fd6b4969 | |||
| fe0f213919 | |||
| e888b5c896 | |||
| f27ff52689 | |||
| acbe0ac25d | |||
| c66ff2bf05 | |||
| d089487aa8 | |||
| 6bc1d1606f | |||
| 9df5d921c7 | |||
| 4b1dfa1173 | |||
| f98318e737 | |||
| 7fa1278b31 | |||
| 878ec1f6c1 | |||
| 086048780a | |||
| 65eaaecae5 | |||
| aff0415816 | |||
| 6040fe1cbd | |||
| 36bc9c62b0 | |||
| 3901457218 | |||
| 52f4e9d796 | |||
| 183bad43f6 | |||
| 09fe80cae7 | |||
| 817b48dc9d | |||
| c6f8c42c45 | |||
| 0535686129 | |||
| 53a77255ca | |||
| c01506dc44 | |||
| a49817fdc3 | |||
| 52a4608d76 | |||
| 6f5f9386ff | |||
| fddd0607e6 | |||
| 2cb6f56feb | |||
| 46285a5ed0 | |||
| 496836b33b | |||
| af127879a5 | |||
| 38ef452801 | |||
| a9a5d99676 | |||
| e5ab50cebd | |||
| 49194f8141 | |||
| 641ae27efd | |||
| 47a5dec465 | |||
| c93eabb400 | |||
| 664be51de2 | |||
| 492a47321d | |||
| c89f6af8c6 | |||
| 9687c6c8e4 | |||
| ef0a89126a | |||
| 8ce71d628d | |||
| ca95d105c8 | |||
| 12568c2b09 | |||
| 82abb4d4d3 | |||
| 3c0a5dbbe5 | |||
| 0b98eef06f | |||
| 18e69c9f2a | |||
| 8f5d06d420 | |||
| 8a299461a0 | |||
| 0c58bec921 | |||
| e0cb132686 | |||
| 2a192f39f9 | |||
| b7ebb7d499 | |||
| fffeac21b7 | |||
| 4387cb485d | |||
| 2a58dc823a | |||
| 8eb38271ff | |||
| 1987ad1d4f | |||
| cc4dae60ef | |||
| 1943116aa1 | |||
| 3485d26b11 | |||
| 4a60aa9f20 | |||
| cda07c9675 | |||
| ca64a77ed0 | |||
| 30e94d1d6f | |||
| b8c6ebfa53 | |||
| b26748ded8 | |||
| f186da0834 | |||
| c52c2d886a | |||
| e5dc1f8a58 | |||
| 6dbf4134de | |||
| e1cc49a74d | |||
| 4489a400b7 | |||
| 28aa1c0b22 | |||
| c540788d20 | |||
| 4ab07dc875 | |||
| 5033de13ef | |||
| 55a8787a16 | |||
| 3515bf364d | |||
| d8f3246e46 | |||
| cd613aaba2 | |||
| c5f84b568b | |||
| 14dc78984f | |||
| fb61dbfa6c | |||
| 33855f17dd | |||
| 8124623142 | |||
| e99c91ce6e | |||
| 177ce5721f | |||
| 4fb0b1dd08 | |||
| 177ad2ce7c | |||
| 9b88769585 | |||
| fd044072a1 | |||
| bae5155e19 | |||
| 1e2085b990 | |||
| bbe5a7d50b | |||
| e5473cdfe4 | |||
| 9c7a70e056 | |||
| 5bc8e86353 | |||
| 5b00465558 | |||
| 28b70f6459 | |||
| 6961cdee95 | |||
| 58557505ae | |||
| 65178b259f | |||
| 541c7f34b7 | |||
| b2c209837c | |||
| 355f61188a | |||
| ea672c2423 | |||
| 71ba6b8e55 | |||
| 8a07fccf8f | |||
| 7bc35f4cee | |||
| 002081bcb9 | |||
| 0e8e78362b | |||
| cb6a5a478e | |||
| d615030222 | |||
| e43c01da64 | |||
| cb5ef1d6e5 | |||
| 78a7dcb7e8 | |||
| 8cca9f3eeb | |||
| 93d4d3c976 | |||
| 8284b56075 | |||
| 580caeffb9 | |||
| b266037bb4 | |||
| cb743de7fd | |||
| 7cf78c6635 | |||
| 0eca30367f | |||
| 36317c953a | |||
| f910593fb6 | |||
| 7942efa202 | |||
| 792c2931b0 | |||
| a3778af48a | |||
| 163dc7e1d1 | |||
| 0a59122ac2 | |||
| d2a5110f3b | |||
| cf4bbf94e4 | |||
| d7e42471a4 | |||
| d634c41e75 | |||
| 6b88397f82 | |||
| da3c709ff0 | |||
| ccd320d8ff | |||
| 3831e61d10 | |||
| e46e7b74e2 | |||
| b3da77a6bc | |||
| 96a74f8955 | |||
| 3ea17e6f46 | |||
| a8ac2c3af9 | |||
| 2168cbca30 | |||
| cceb45319a | |||
| e985b78241 | |||
| 090ca828c0 | |||
| 3522925dec | |||
| 5418ef7ae2 | |||
| 5faeddb99b | |||
| 1140c3e2e7 | |||
| 3fb08d27c7 | |||
| 250940d083 | |||
| e00be8f010 | |||
| 8b471c0772 | |||
| 88e738c796 | |||
| c76d8c79d8 | |||
| b396431a8b | |||
| d1795a82f7 | |||
| 9b821a0dfe | |||
| bf89842ee8 | |||
| d274e80f75 | |||
| a98b8945fb | |||
| 6b72599f80 | |||
| 4b6fe78a1a | |||
| d000c03fca | |||
| c0a185ba68 | |||
| ef0813e638 | |||
| 25e9f44260 | |||
| 2d6e858e8f | |||
| 53bf7c5068 | |||
| 13fb686188 | |||
| 61c5494588 | |||
| d96fefbc24 | |||
| e18b7c1013 | |||
| f190b51dcc | |||
| ca41c12f7c | |||
| 844edbe2f4 | |||
| 78974c02e5 | |||
| 4508464fd1 | |||
| dd6455a559 | |||
| 6b147b098a | |||
| 834f8674a3 | |||
| 5cee331abe | |||
| 98c00f7a60 | |||
| db8d946178 | |||
| b97a86f6dc | |||
| 34a4e6be3d | |||
| 22c5ea5000 | |||
| 79acf6c0ba | |||
| ebaa01896f | |||
| d4811b7901 | |||
| bf409967b2 | |||
| 4cb658daca | |||
| 8aeddcf8d8 | |||
| fb81e1bdd5 | |||
| d5b9e3c960 | |||
| c7ff536ed5 | |||
| 7dbb5fc86d | |||
| 8f766bcbaa | |||
| f65c6c89ae | |||
| 472462cdcb | |||
| 8575996e46 | |||
| 02d16ca510 | |||
| 1f69048c86 | |||
| 442aafd2c5 | |||
| 6082a6549a | |||
| 2567702b44 | |||
| ec981ac547 | |||
| d0d4ada7c2 | |||
| 54cbe3faa4 | |||
| 49e51de274 | |||
| e456035f29 | |||
| 964974c142 | |||
| b63eb1c8b4 | |||
| 603bcf7d9d | |||
| 0468a23c4f | |||
| f95e29df45 | |||
| 79d95d9477 | |||
| a406ba4ca0 | |||
| dfbda7c10b | |||
| c11ecd3323 | |||
| 8a5c39ee53 | |||
| c38035188b | |||
| ba02d372f7 | |||
| 5312b3694b | |||
| d0800bb31c | |||
| e272d38ca5 | |||
| 66517af81c | |||
| d2925ee3f9 | |||
| 729714375b | |||
| b77643b928 | |||
| 0491babe0a | |||
| 0adb36cfb8 | |||
| 8dc486f18f | |||
| a229ba9c15 | |||
| e4eed2e519 | |||
| 395eac26a3 | |||
| e9d7ddebb2 | |||
| 5dc1179d54 | |||
| 8decdf4346 | |||
| 98fd6240ba | |||
| 5a77528526 | |||
| d4fdced538 | |||
| 8b1bbdf360 | |||
| eae4cca148 | |||
| a194046168 | |||
| 7c6ed7bb31 | |||
| 20123d8245 | |||
| 650945418d | |||
| 064facb048 | |||
| 0bc1b5e0d3 | |||
| 65f6822199 | |||
| 021d2a8a54 | |||
| 5fa8f3ef6f | |||
| 10dffdbde2 | |||
| 72716afcd3 | |||
| 00468c7d0e | |||
| b7b1316e70 | |||
| 97a9e63231 | |||
| 3f50ab7cfc | |||
| 341a06aae7 | |||
| c48260f10c | |||
| 12a2517697 | |||
| 49698ea669 | |||
| 834202411d | |||
| 94e152bb57 | |||
| 5adcc3efad | |||
| b65bc65d7c | |||
| 06958c424c | |||
| 02896cac03 | |||
| c0ec1bc5cf | |||
| 40968d573c | |||
| 9f848e3e76 | |||
| f765fb63f0 | |||
| ff6a486daf | |||
| bb6ad14111 | |||
| a2207a2cb3 | |||
| c764d657d7 | |||
| 2b6cecc441 | |||
| 61d83be52e | |||
| 9f2362d346 | |||
| 193c3823b6 | |||
| 09d9f72db2 | |||
| 3d41d04818 | |||
| 46ac0a1ed3 | |||
| e6d77c165e | |||
| 18f041f1c6 | |||
| 9c0a633677 | |||
| 095196785a | |||
| 80b1207640 | |||
| ba6244780c | |||
| d8dc4656e4 | |||
| 30675e0567 | |||
| e6efddc639 | |||
| 47eace97bd | |||
| 980ffb45e9 | |||
| 17fd499420 | |||
| 0e9b15722a | |||
| 724c213af1 | |||
| a215035d07 | |||
| 25d0f50f3d | |||
| 6d44a579a4 | |||
| 106e461beb | |||
| 6472002f8a | |||
| 11bd1adbd4 | |||
| 28d366ab19 | |||
| 10e29090d8 | |||
| 73b0ddc2ce | |||
| 7c8e946871 | |||
| d12d16348a | |||
| 421fe67930 | |||
| 8291cdfc12 | |||
| 2e6fffc903 | |||
| 8b6c60bb17 | |||
| 1cae35a62b | |||
| 70b03b71e4 | |||
| 317521bba6 | |||
| 061e4a9e5f | |||
| ec3adff706 | |||
| 5524a14c87 | |||
| e1e8c943f3 | |||
| 0c6630d2d9 | |||
| 80a7d2c255 | |||
| 2b3a20c5ff | |||
| 216205200c | |||
| 61e7124516 | |||
| 2ab216effc | |||
| 1e59301c0e | |||
| d5a2c1cad6 | |||
| b5c60ee6a9 | |||
| 533b8a8cb7 | |||
| fecb193ded | |||
| dc5d257082 | |||
| c690473b2e | |||
| 04fa5eb289 | |||
| d6cd3a0ead | |||
| a23f281734 | |||
| 135d58e1f5 | |||
| 832ff19e51 | |||
| c7bef5ac7b | |||
| 2254cac15b | |||
| 01d574a302 | |||
| d67d697847 | |||
| 8c9e37f8a1 | |||
| 77393c5324 | |||
| e66db051a9 | |||
| 5670a6d1b4 | |||
| 02d45bed74 | |||
| adc0d145c3 | |||
| 58481e3133 | |||
| a3ec9b7b78 | |||
| 2d534b0293 | |||
| d73d0cf8ce | |||
| fbe490c28d | |||
| c09de43154 | |||
| be651ebb4b | |||
| 3fe793cd4d | |||
| a8cfe399b3 | |||
| ff9e39e2ee | |||
| 0d2b61472c | |||
| f9f3482bf1 | |||
| 400a2a9bab | |||
| 36864b4c2f | |||
| 5671b99b7e | |||
| 76f88686da | |||
| ba42a8b269 | |||
| ae7def2313 | |||
| e55176511f | |||
| 640f146373 | |||
| ebe8755613 | |||
| 05eee7cb0f | |||
| 7ac9fda1eb | |||
| 6987a0a585 | |||
| 1a338fb9e5 | |||
| 8dc18bbe5e | |||
| 64c2b32b24 | |||
| f01ef5d955 | |||
| 221ee0be05 | |||
| 2b8ba02c2a | |||
| b06583afce | |||
| b8b1ae7e88 | |||
| b67b1bed13 | |||
| 33fa9f8f50 | |||
| a7087aaa38 | |||
| a7170762d4 | |||
| ea3d198723 | |||
| fe8f048571 | |||
| 09d2feb15b | |||
| 33d4c1a60e | |||
| cbd7a13275 | |||
| 903b7f8718 | |||
| 88cd1651c3 | |||
| 13ff5f26a2 | |||
| 57345a5fd2 | |||
| bd82bd2249 | |||
| f253a69656 | |||
| 493a5835f8 | |||
| bffbcb229d | |||
| b5ec431b0a | |||
| 259706478f | |||
| c9f172ef21 | |||
| eb32c98b76 | |||
| 95c3f04c10 | |||
| d4daf7231c | |||
| 9a95f435ad | |||
| 5218b80cab | |||
| b1665c880b | |||
| 6395dfe425 | |||
| 304ad6e767 | |||
| 587a093aff | |||
| 91b49e4a16 | |||
| e72915c5d0 | |||
| bee2da567b | |||
| 951689c5ea | |||
| 4146ae60bc | |||
| 6bcf5efb65 | |||
| ddf10f1052 | |||
| 7edca44ed8 | |||
| ebc3f16597 | |||
| 9dad31775c | |||
| b1089b66c3 | |||
| 84142ab27e | |||
| fd518e39ff | |||
| 2fb4cbcf02 | |||
| d3337c7d3c | |||
| 5fd11bf009 | |||
| 92603a1e7f | |||
| 7d8afe3476 | |||
| f9c66ead50 | |||
| a12d5a982f | |||
| c1f176fa21 | |||
| f1dbe4ab6c | |||
| 459ebf7070 | |||
| 8cf0ec16bb | |||
| 87558c67c8 | |||
| 8727a2e299 | |||
| b8c5c87cfa | |||
| ce2970eefa | |||
| 24fea5a24a | |||
| 5285680eed | |||
| 17e63194ad | |||
| 6427b3406c | |||
| df8c77cd3e | |||
| 41796aec06 | |||
| 1355b692b9 | |||
| 2c13ef40e2 | |||
| 2bb67db888 | |||
| 204f384d01 | |||
| 42e3d48caf | |||
| 4747050b60 | |||
| c926db7f13 | |||
| 8cce3f4503 | |||
| 3464b0383c | |||
| d852029d25 | |||
| ca8d62d4e2 | |||
| 0632920a6f | |||
| c5bda4f3be | |||
| 3dc92b4939 | |||
| 150146385f | |||
| f50bd32fa3 | |||
| 0dcf820944 | |||
| 6fd16684f7 | |||
| eaa957168f | |||
| 796a7aaaf1 | |||
| 9aaae7b2d9 | |||
| c00609223b | |||
| 5641c3fc87 | |||
| dd1bdae947 | |||
| 70973b2281 | |||
| 5842a6d42f | |||
| 538ab52abd |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "12.x"
|
||||
node-version: "14.x"
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
|
||||
@ -14,25 +14,73 @@ const defaultConfig = {
|
||||
restartOnConfigChanges: false,
|
||||
trayClickPlayPause: false,
|
||||
autoResetAppCache: false,
|
||||
resumeOnStart: true,
|
||||
proxy: "",
|
||||
},
|
||||
plugins: {
|
||||
// Enabled plugins
|
||||
navigation: {
|
||||
enabled: true,
|
||||
},
|
||||
shortcuts: {
|
||||
enabled: true,
|
||||
},
|
||||
adblocker: {
|
||||
enabled: true,
|
||||
cache: true,
|
||||
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
|
||||
},
|
||||
// Disabled plugins
|
||||
shortcuts: {
|
||||
enabled: false,
|
||||
overrideMediaKeys: false,
|
||||
},
|
||||
downloader: {
|
||||
enabled: false,
|
||||
ffmpegArgs: [], // e.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
|
||||
downloadFolder: undefined, // Custom download folder (absolute path)
|
||||
preset: "mp3",
|
||||
},
|
||||
"last-fm": {
|
||||
enabled: false,
|
||||
api_root: "http://ws.audioscrobbler.com/2.0/",
|
||||
api_key: "04d76faaac8726e60988e14c105d421a", // api key registered by @semvis123
|
||||
secret: "a5d2a36fdf64819290f6982481eaffa2",
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
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
|
||||
},
|
||||
notifications: {
|
||||
enabled: false,
|
||||
unpauseNotification: false,
|
||||
urgency: "normal", //has effect only on Linux
|
||||
interactive: false //has effect only on Windows
|
||||
},
|
||||
"precise-volume": {
|
||||
enabled: false,
|
||||
steps: 1, //percentage of volume to change
|
||||
arrowsShortcut: true, //enable ArrowUp + ArrowDown local shortcuts
|
||||
globalShortcuts: {
|
||||
volumeUp: "",
|
||||
volumeDown: ""
|
||||
},
|
||||
savedVolume: undefined //plugin save volume between session here
|
||||
},
|
||||
sponsorblock: {
|
||||
enabled: false,
|
||||
apiURL: "https://sponsor.ajay.app",
|
||||
categories: [
|
||||
"sponsor",
|
||||
"intro",
|
||||
"outro",
|
||||
"interaction",
|
||||
"selfpromo",
|
||||
"music_offtopic",
|
||||
],
|
||||
},
|
||||
"video-toggle": {
|
||||
enabled: false,
|
||||
forceHide: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const defaultConfig = require("./defaults");
|
||||
const plugins = require("./plugins");
|
||||
const store = require("./store");
|
||||
|
||||
@ -10,6 +11,7 @@ const get = (key) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
defaultConfig,
|
||||
get,
|
||||
set,
|
||||
edit: () => store.openInEditor(),
|
||||
|
||||
@ -24,6 +24,10 @@ function setOptions(plugin, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function getOptions(plugin) {
|
||||
return store.get("plugins")[plugin];
|
||||
}
|
||||
|
||||
function enable(plugin) {
|
||||
setOptions(plugin, { enabled: true });
|
||||
}
|
||||
@ -38,4 +42,5 @@ module.exports = {
|
||||
enable,
|
||||
disable,
|
||||
setOptions,
|
||||
getOptions,
|
||||
};
|
||||
|
||||
@ -3,6 +3,49 @@ const Store = require("electron-store");
|
||||
const defaults = require("./defaults");
|
||||
|
||||
const migrations = {
|
||||
">=1.14.0": (store) => {
|
||||
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) => {
|
||||
if (store.get("plugins.discord.listenAlong") === undefined) {
|
||||
store.set("plugins.discord.listenAlong", true);
|
||||
}
|
||||
},
|
||||
">=1.12.0": (store) => {
|
||||
const options = store.get("plugins.shortcuts");
|
||||
let updated = false;
|
||||
for (const optionType of ["global", "local"]) {
|
||||
if (Array.isArray(options[optionType])) {
|
||||
const updatedOptions = {};
|
||||
for (const optionObject of options[optionType]) {
|
||||
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) => {
|
||||
if (store.get("options.resumeOnStart") === undefined) {
|
||||
store.set("options.resumeOnStart", true);
|
||||
}
|
||||
},
|
||||
">=1.7.0": (store) => {
|
||||
const enabledPlugins = store.get("plugins");
|
||||
if (!Array.isArray(enabledPlugins)) {
|
||||
|
||||
249
index.js
249
index.js
@ -2,7 +2,11 @@
|
||||
const path = require("path");
|
||||
|
||||
const electron = require("electron");
|
||||
const remote = require('@electron/remote/main');
|
||||
remote.initialize();
|
||||
const enhanceWebRequest = require("electron-better-web-request").default;
|
||||
const is = require("electron-is");
|
||||
const unhandled = require("electron-unhandled");
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
|
||||
const config = require("./config");
|
||||
@ -10,13 +14,21 @@ const { setApplicationMenu } = require("./menu");
|
||||
const { fileExists, injectCSS } = require("./plugins/utils");
|
||||
const { isTesting } = require("./utils/testing");
|
||||
const { setUpTray } = require("./tray");
|
||||
const { setupSongInfo } = require("./providers/song-info");
|
||||
|
||||
// Catch errors and log them
|
||||
unhandled({
|
||||
logger: console.error,
|
||||
showDialog: false,
|
||||
});
|
||||
|
||||
const app = electron.app;
|
||||
app.commandLine.appendSwitch(
|
||||
"js-flags",
|
||||
// WebAssembly flags
|
||||
"--experimental-wasm-threads --experimental-wasm-bulk-memory"
|
||||
"--experimental-wasm-threads"
|
||||
);
|
||||
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); // Required for downloader
|
||||
app.allowRendererProcessReuse = true; // https://github.com/electron/electron/issues/18397
|
||||
if (config.get("options.disableHardwareAcceleration")) {
|
||||
if (is.dev()) {
|
||||
@ -25,8 +37,14 @@ if (config.get("options.disableHardwareAcceleration")) {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
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
|
||||
require("electron-debug")();
|
||||
require("electron-debug")({
|
||||
showDevTools: false //disable automatic devTools on new window
|
||||
});
|
||||
|
||||
// Prevent window being garbage collected
|
||||
let mainWindow;
|
||||
@ -47,7 +65,7 @@ function onClosed() {
|
||||
|
||||
function loadPlugins(win) {
|
||||
injectCSS(win.webContents, path.join(__dirname, "youtube-music.css"));
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
if (is.dev()) {
|
||||
console.log("did finish load");
|
||||
win.webContents.openDevTools();
|
||||
@ -68,6 +86,7 @@ function createMainWindow() {
|
||||
const windowSize = config.get("window-size");
|
||||
const windowMaximized = config.get("window-maximized");
|
||||
const windowPosition = config.get("window-position");
|
||||
const useInlineMenu = config.plugins.isEnabled("in-app-menu");
|
||||
|
||||
const win = new electron.BrowserWindow({
|
||||
icon: icon,
|
||||
@ -82,20 +101,24 @@ function createMainWindow() {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nativeWindowOpen: true, // window.open return Window object(like in regular browsers), not BrowserWindowProxy
|
||||
enableRemoteModule: true,
|
||||
affinity: "main-window", // main window, and addition windows should work in one process
|
||||
...(isTesting()
|
||||
? {
|
||||
// Only necessary when testing with Spectron
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
}
|
||||
// Only necessary when testing with Spectron
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
frame: !is.macOS(),
|
||||
titleBarStyle: is.macOS() ? "hiddenInset" : "default",
|
||||
frame: !is.macOS() && !useInlineMenu,
|
||||
titleBarStyle: useInlineMenu
|
||||
? "hidden"
|
||||
: is.macOS()
|
||||
? "hiddenInset"
|
||||
: "default",
|
||||
autoHideMenuBar: config.get("options.hideMenu"),
|
||||
});
|
||||
remote.enable(win.webContents);
|
||||
if (windowPosition) {
|
||||
const { x, y } = windowPosition;
|
||||
win.setPosition(x, y);
|
||||
@ -104,7 +127,10 @@ function createMainWindow() {
|
||||
win.maximize();
|
||||
}
|
||||
|
||||
win.webContents.loadURL(config.get("url"));
|
||||
const urlToLoad = config.get("options.resumeOnStart")
|
||||
? config.get("url")
|
||||
: config.defaultConfig.url;
|
||||
win.webContents.loadURL(urlToLoad);
|
||||
win.on("closed", onClosed);
|
||||
|
||||
win.on("move", () => {
|
||||
@ -124,51 +150,81 @@ function createMainWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.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.on("browser-window-created", (event, win) => {
|
||||
app.once("browser-window-created", (event, win) => {
|
||||
// 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);
|
||||
loadPlugins(win);
|
||||
|
||||
win.webContents.on("did-fail-load", () => {
|
||||
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("did fail load");
|
||||
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.loadFile(path.join(__dirname, "error.html"));
|
||||
});
|
||||
|
||||
win.webContents.on("will-prevent-unload", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate-in-page", () => {
|
||||
const url = win.webContents.getURL();
|
||||
if (url.startsWith("https://music.youtube.com")) {
|
||||
config.set("url", url);
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.on("will-navigate", (_, url) => {
|
||||
if (url.startsWith("https://accounts.google.com")) {
|
||||
// Force user-agent "Firefox Windows" for Google OAuth to work
|
||||
// From https://github.com/firebase/firebase-js-sdk/issues/2478#issuecomment-571356751
|
||||
// Only set on accounts.google.com, otherwise querySelectors in preload scripts fail (?)
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0";
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||
details.requestHeaders["User-Agent"] = userAgent;
|
||||
cb({ requestHeaders: details.requestHeaders });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.on(
|
||||
"new-window",
|
||||
(e, url, frameName, disposition, options) => {
|
||||
@ -202,8 +258,43 @@ app.on("activate", () => {
|
||||
|
||||
app.on("ready", () => {
|
||||
if (config.get("options.autoResetAppCache")) {
|
||||
// Clear cache
|
||||
electron.session.defaultSession.clearCache();
|
||||
// Clear cache after 20s
|
||||
const clearCacheTimeout = setTimeout(() => {
|
||||
if (is.dev()) {
|
||||
console.log("Clearing app cache.");
|
||||
}
|
||||
electron.session.defaultSession.clearCache();
|
||||
clearTimeout(clearCacheTimeout);
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
// 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 = electron.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
|
||||
electron.shell.writeShortcutLink(
|
||||
shortcutPath,
|
||||
error === "needUpdate" ? "update" : "create",
|
||||
{
|
||||
target: appLocation,
|
||||
cwd: appLocation.slice(0, appLocation.lastIndexOf(path.sep)),
|
||||
description: "YouTube Music Desktop App - including custom plugins",
|
||||
appUserModelId: appID
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
@ -253,14 +344,20 @@ app.on("ready", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Optimized for Mac OS X
|
||||
if (is.macOS()) {
|
||||
if (!config.get("options.appVisible")) {
|
||||
app.dock.hide();
|
||||
}
|
||||
if (config.get("options.hideMenu") && !config.get("options.hideMenuWarned")) {
|
||||
electron.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);
|
||||
}
|
||||
|
||||
var forceQuit = false;
|
||||
// Optimized for Mac OS X
|
||||
if (is.macOS() && !config.get("options.appVisible")) {
|
||||
app.dock.hide();
|
||||
}
|
||||
|
||||
let forceQuit = false;
|
||||
app.on("before-quit", () => {
|
||||
forceQuit = true;
|
||||
});
|
||||
@ -275,3 +372,65 @@ app.on("ready", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function showUnresponsiveDialog(win, details) {
|
||||
if (!!details) {
|
||||
console.log("Unresponsive Error!\n"+JSON.stringify(details, null, "\t"))
|
||||
}
|
||||
electron.dialog.showMessageBox(win, {
|
||||
type: "error",
|
||||
title: "Window Unresponsive",
|
||||
message: "The Application is Unresponsive",
|
||||
details: "We are sorry for the inconvenience! please choose what to do:",
|
||||
buttons: ["Wait", "Relaunch", "Quit"],
|
||||
cancelId: 0
|
||||
}).then( result => {
|
||||
switch (result.response) {
|
||||
case 1: //if relaunch - relaunch+exit
|
||||
app.relaunch();
|
||||
case 2:
|
||||
app.quit();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeContentSecurityPolicy(
|
||||
session = electron.session.defaultSession
|
||||
) {
|
||||
// Allows defining multiple "onHeadersReceived" listeners
|
||||
// by enhancing the session.
|
||||
// Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener
|
||||
enhanceWebRequest(session);
|
||||
|
||||
// Custom listener to tweak the content security policy
|
||||
session.webRequest.onHeadersReceived(function (details, callback) {
|
||||
if (
|
||||
!details.responseHeaders["content-security-policy-report-only"] &&
|
||||
!details.responseHeaders["content-security-policy"]
|
||||
)
|
||||
return callback({ cancel: false });
|
||||
delete details.responseHeaders["content-security-policy-report-only"];
|
||||
delete details.responseHeaders["content-security-policy"];
|
||||
callback({ cancel: false, responseHeaders: details.responseHeaders });
|
||||
});
|
||||
|
||||
// When multiple listeners are defined, apply them all
|
||||
session.webRequest.setResolver("onHeadersReceived", (listeners) => {
|
||||
const response = listeners.reduce(
|
||||
async (accumulator, listener) => {
|
||||
if (accumulator.cancel) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
{ cancel: false }
|
||||
);
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,5 @@ module.exports = {
|
||||
globals: {
|
||||
__APP__: undefined, // A different app will be launched in each test environment
|
||||
},
|
||||
testEnvironment: "./tests/environment",
|
||||
testTimeout: 30000, // 30s
|
||||
};
|
||||
|
||||
382
menu.js
382
menu.js
@ -1,87 +1,114 @@
|
||||
const { app, Menu } = require("electron");
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { app, Menu, dialog } = require("electron");
|
||||
const is = require("electron-is");
|
||||
|
||||
const { getAllPlugins } = require("./plugins/utils");
|
||||
const config = require("./config");
|
||||
|
||||
const mainMenuTemplate = (win) => [
|
||||
{
|
||||
label: "Plugins",
|
||||
submenu: [
|
||||
...getAllPlugins().map((plugin) => {
|
||||
return {
|
||||
label: plugin,
|
||||
type: "checkbox",
|
||||
checked: config.plugins.isEnabled(plugin),
|
||||
click: (item) => {
|
||||
if (item.checked) {
|
||||
config.plugins.enable(plugin);
|
||||
} else {
|
||||
config.plugins.disable(plugin);
|
||||
}
|
||||
},
|
||||
};
|
||||
}),
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Advanced options",
|
||||
click: () => {
|
||||
config.edit();
|
||||
},
|
||||
},
|
||||
],
|
||||
const prompt = require("custom-electron-prompt");
|
||||
const promptOptions = require("./providers/prompt-options");
|
||||
|
||||
// true only if in-app-menu was loaded on launch
|
||||
const inAppMenuActive = config.plugins.isEnabled("in-app-menu");
|
||||
|
||||
const pluginEnabledMenu = (plugin, label = "", hasSubmenu = false, refreshMenu = undefined) => ({
|
||||
label: label || plugin,
|
||||
type: "checkbox",
|
||||
checked: config.plugins.isEnabled(plugin),
|
||||
click: (item) => {
|
||||
if (item.checked) {
|
||||
config.plugins.enable(plugin);
|
||||
} else {
|
||||
config.plugins.disable(plugin);
|
||||
}
|
||||
if (hasSubmenu) {
|
||||
refreshMenu();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Options",
|
||||
submenu: [
|
||||
{
|
||||
label: "Auto-update",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.autoUpdates"),
|
||||
click: (item) => {
|
||||
config.set("options.autoUpdates", item.checked);
|
||||
});
|
||||
|
||||
const mainMenuTemplate = (win) => {
|
||||
const refreshMenu = () => {
|
||||
this.setApplicationMenu(win);
|
||||
if (inAppMenuActive) {
|
||||
win.webContents.send("updateMenu", true);
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: "Plugins",
|
||||
submenu: [
|
||||
...getAllPlugins().map((plugin) => {
|
||||
const pluginPath = path.join(__dirname, "plugins", plugin, "menu.js")
|
||||
if (existsSync(pluginPath)) {
|
||||
if (!config.plugins.isEnabled(plugin)) {
|
||||
return pluginEnabledMenu(plugin, "", true, refreshMenu);
|
||||
}
|
||||
const getPluginMenu = require(pluginPath);
|
||||
return {
|
||||
label: plugin,
|
||||
submenu: [
|
||||
pluginEnabledMenu(plugin, "Enabled", true, refreshMenu),
|
||||
...getPluginMenu(win, config.plugins.getOptions(plugin), refreshMenu),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return pluginEnabledMenu(plugin);
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Options",
|
||||
submenu: [
|
||||
{
|
||||
label: "Auto-update",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.autoUpdates"),
|
||||
click: (item) => {
|
||||
config.set("options.autoUpdates", item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Disable hardware acceleration",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.disableHardwareAcceleration"),
|
||||
click: (item) => {
|
||||
config.set("options.disableHardwareAcceleration", item.checked);
|
||||
{
|
||||
label: "Resume last song when app starts",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.resumeOnStart"),
|
||||
click: (item) => {
|
||||
config.set("options.resumeOnStart", item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restart on config changes",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.restartOnConfigChanges"),
|
||||
click: (item) => {
|
||||
config.set("options.restartOnConfigChanges", item.checked);
|
||||
{
|
||||
label: "Remove upgrade button",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.removeUpgradeButton"),
|
||||
click: (item) => {
|
||||
config.set("options.removeUpgradeButton", item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset App cache when app starts",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.autoResetAppCache"),
|
||||
click: (item) => {
|
||||
config.set("options.autoResetAppCache", item.checked);
|
||||
},
|
||||
},
|
||||
...(is.windows() || is.linux()
|
||||
? [
|
||||
...(is.windows() || is.linux()
|
||||
? [
|
||||
{
|
||||
label: "Hide menu",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.hideMenu"),
|
||||
click: (item) => {
|
||||
config.set("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 'Escape' if using in-app-menu)"
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(is.windows() || is.macOS()
|
||||
? // Only works on Win/Mac
|
||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||
[
|
||||
]
|
||||
: []),
|
||||
...(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",
|
||||
@ -90,74 +117,155 @@ const mainMenuTemplate = (win) => [
|
||||
config.set("options.startAtLogin", item.checked);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Tray",
|
||||
submenu: [
|
||||
{
|
||||
label: "Disabled",
|
||||
type: "radio",
|
||||
checked: !config.get("options.tray"),
|
||||
click: () => {
|
||||
config.set("options.tray", false);
|
||||
config.set("options.appVisible", true);
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Tray",
|
||||
submenu: [
|
||||
{
|
||||
label: "Disabled",
|
||||
type: "radio",
|
||||
checked: !config.get("options.tray"),
|
||||
click: () => {
|
||||
config.set("options.tray", false);
|
||||
config.set("options.appVisible", true);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Enabled + app visible",
|
||||
type: "radio",
|
||||
checked:
|
||||
config.get("options.tray") && config.get("options.appVisible"),
|
||||
click: () => {
|
||||
config.set("options.tray", true);
|
||||
config.set("options.appVisible", true);
|
||||
{
|
||||
label: "Enabled + app visible",
|
||||
type: "radio",
|
||||
checked:
|
||||
config.get("options.tray") && config.get("options.appVisible"),
|
||||
click: () => {
|
||||
config.set("options.tray", true);
|
||||
config.set("options.appVisible", true);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Enabled + app hidden",
|
||||
type: "radio",
|
||||
checked:
|
||||
config.get("options.tray") && !config.get("options.appVisible"),
|
||||
click: () => {
|
||||
config.set("options.tray", true);
|
||||
config.set("options.appVisible", false);
|
||||
{
|
||||
label: "Enabled + app hidden",
|
||||
type: "radio",
|
||||
checked:
|
||||
config.get("options.tray") && !config.get("options.appVisible"),
|
||||
click: () => {
|
||||
config.set("options.tray", true);
|
||||
config.set("options.appVisible", false);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Play/Pause on click",
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Play/Pause on click",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.trayClickPlayPause"),
|
||||
click: (item) => {
|
||||
config.set("options.trayClickPlayPause", item.checked);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Advanced options",
|
||||
submenu: [
|
||||
{
|
||||
label: "Proxy",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.trayClickPlayPause"),
|
||||
checked: !!config.get("options.proxy"),
|
||||
click: (item) => {
|
||||
config.set("options.trayClickPlayPause", item.checked);
|
||||
setProxy(item, win);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Disable hardware acceleration",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.disableHardwareAcceleration"),
|
||||
click: (item) => {
|
||||
config.set("options.disableHardwareAcceleration", item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restart on config changes",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.restartOnConfigChanges"),
|
||||
click: (item) => {
|
||||
config.set("options.restartOnConfigChanges", item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset App cache when app starts",
|
||||
type: "checkbox",
|
||||
checked: config.get("options.autoResetAppCache"),
|
||||
click: (item) => {
|
||||
config.set("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 {
|
||||
const devToolsOptions = {};
|
||||
webContents.openDevTools(devToolsOptions);
|
||||
}
|
||||
},
|
||||
} :
|
||||
{ role: "toggleDevTools" },
|
||||
{
|
||||
label: "Edit config.json",
|
||||
click: () => {
|
||||
config.edit();
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ type: "separator" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ role: "resetZoom" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Navigation",
|
||||
submenu: [
|
||||
{
|
||||
label: "Go back",
|
||||
click: () => {
|
||||
if (win.webContents.canGoBack()) {
|
||||
win.webContents.goBack();
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Toggle DevTools",
|
||||
// Cannot use "toggleDevTools" role in MacOS
|
||||
click: () => {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
webContents.closeDevTools();
|
||||
} else {
|
||||
const devToolsOptions = {};
|
||||
webContents.openDevTools(devToolsOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Advanced options",
|
||||
click: () => {
|
||||
config.edit();
|
||||
{
|
||||
label: "Go forward",
|
||||
click: () => {
|
||||
if (win.webContents.canGoForward()) {
|
||||
win.webContents.goForward();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
{
|
||||
label: "Restart App",
|
||||
click: () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
module.exports.mainMenuTemplate = mainMenuTemplate;
|
||||
module.exports.setApplicationMenu = (win) => {
|
||||
@ -192,3 +300,25 @@ module.exports.setApplicationMenu = (win) => {
|
||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
||||
Menu.setApplicationMenu(menu);
|
||||
};
|
||||
|
||||
async function setProxy(item, win) {
|
||||
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.set("options.proxy", output);
|
||||
item.checked = output !== "";
|
||||
} else { //user pressed cancel
|
||||
item.checked = !item.checked; //reset checkbox
|
||||
}
|
||||
}
|
||||
|
||||
83
package.json
83
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "youtube-music",
|
||||
"productName": "YouTube Music",
|
||||
"version": "1.10.0",
|
||||
"version": "1.15.0",
|
||||
"description": "YouTube Music Desktop App - including custom plugins",
|
||||
"license": "MIT",
|
||||
"repository": "th-ch/youtube-music",
|
||||
@ -19,7 +19,10 @@
|
||||
},
|
||||
"win": {
|
||||
"icon": "assets/generated/icons/win/icon.ico",
|
||||
"target": ["nsis", "portable"]
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"runAfterFinish": false
|
||||
@ -34,11 +37,21 @@
|
||||
"deb",
|
||||
"rpm"
|
||||
]
|
||||
},
|
||||
"snap": {
|
||||
"slots": [
|
||||
{
|
||||
"mpris": {
|
||||
"interface": "mpris"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"start": "electron .",
|
||||
"start": "NODE_OPTIONS= electron .",
|
||||
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron .",
|
||||
"icon": "rimraf assets/generated && electron-icon-maker --input=assets/youtube-music.png --output=assets/generated",
|
||||
"generate:package": "node utils/generate-package-json.js",
|
||||
"postinstall": "yarn run icon && yarn run plugins",
|
||||
@ -47,51 +60,73 @@
|
||||
"build:linux": "yarn run clean && electron-builder --linux",
|
||||
"build:mac": "yarn run clean && electron-builder --mac",
|
||||
"build:win": "yarn run clean && electron-builder --win",
|
||||
"plugins": "yarn run plugin:adblocker && yarn run plugin:autoconfirm",
|
||||
"lint": "xo",
|
||||
"plugins": "yarn run plugin:adblocker",
|
||||
"plugin:adblocker": "rimraf plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
|
||||
"plugin:autoconfirm": "yarn run generate:package YoutubeNonStop",
|
||||
"release:linux": "yarn run clean && electron-builder --linux -p always -c.snap.publish=github",
|
||||
"release:mac": "yarn run clean && electron-builder --mac -p always",
|
||||
"release:win": "yarn run clean && electron-builder --win -p always"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.16.1",
|
||||
"node": ">=14.0.0",
|
||||
"npm": "Please use yarn and not npm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "^1.19.0",
|
||||
"@ffmpeg/core": "^0.8.5",
|
||||
"@ffmpeg/ffmpeg": "^0.9.7",
|
||||
"YoutubeNonStop": "git://github.com/lawfx/YoutubeNonStop.git#v0.8.1",
|
||||
"discord-rpc": "^3.1.4",
|
||||
"downloads-folder": "^3.0.1",
|
||||
"@cliqz/adblocker-electron": "^1.23.1",
|
||||
"@electron/remote": "^2.0.1",
|
||||
"@ffmpeg/core": "^0.10.0",
|
||||
"@ffmpeg/ffmpeg": "^0.10.0",
|
||||
"async-mutex": "^0.3.2",
|
||||
"browser-id3-writer": "^4.4.0",
|
||||
"chokidar": "^3.5.2",
|
||||
"custom-electron-prompt": "^1.4.0",
|
||||
"custom-electron-titlebar": "^3.2.9",
|
||||
"discord-rpc": "^3.2.0",
|
||||
"electron-better-web-request": "^1.0.1",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-store": "^6.0.1",
|
||||
"electron-updater": "^4.3.6",
|
||||
"filenamify": "^4.2.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"ytdl-core": "^4.3.0"
|
||||
"electron-store": "^7.0.3",
|
||||
"electron-unhandled": "^3.0.2",
|
||||
"electron-updater": "^4.6.3",
|
||||
"filenamify": "^4.3.0",
|
||||
"hark": "^1.2.3",
|
||||
"md5": "^2.3.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"node-fetch": "^2.6.6",
|
||||
"node-notifier": "^9.0.1",
|
||||
"ytdl-core": "^4.9.2",
|
||||
"ytpl": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^11.1.1",
|
||||
"electron-builder": "^22.8.1",
|
||||
"electron": "^16.0.5",
|
||||
"electron-builder": "^22.10.5",
|
||||
"electron-devtools-installer": "^3.1.1",
|
||||
"electron-icon-maker": "0.0.5",
|
||||
"get-port": "^5.1.1",
|
||||
"jest": "^26.4.2",
|
||||
"jest": "^27.3.1",
|
||||
"playwright": "^1.17.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"spectron": "^13.0.0",
|
||||
"xo": "^0.33.1"
|
||||
"xo": "^0.45.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"glob-parent": "5.1.2",
|
||||
"minimist": "1.2.5",
|
||||
"yargs-parser": "18.1.3"
|
||||
},
|
||||
"xo": {
|
||||
"envs": [
|
||||
"node",
|
||||
"browser"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"avoidEscape": true,
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,9 @@ 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",
|
||||
// Fanboy Annoyances
|
||||
"https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt",
|
||||
];
|
||||
|
||||
const loadAdBlockerEngine = (
|
||||
@ -31,7 +33,17 @@ const loadAdBlockerEngine = (
|
||||
...additionalBlockLists,
|
||||
];
|
||||
|
||||
ElectronBlocker.fromLists(fetch, lists, {}, cachingOptions)
|
||||
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);
|
||||
|
||||
17
plugins/audio-compressor/front.js
Normal file
17
plugins/audio-compressor/front.js
Normal file
@ -0,0 +1,17 @@
|
||||
const applyCompressor = () => {
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
const compressor = audioContext.createDynamicsCompressor();
|
||||
compressor.threshold.value = -50;
|
||||
compressor.ratio.value = 12;
|
||||
compressor.knee.value = 40;
|
||||
compressor.attack.value = 0;
|
||||
compressor.release.value = 0.25;
|
||||
|
||||
const source = audioContext.createMediaElementSource(document.querySelector("video"));
|
||||
|
||||
source.connect(compressor);
|
||||
compressor.connect(audioContext.destination);
|
||||
};
|
||||
|
||||
module.exports = () => document.addEventListener('apiLoaded', applyCompressor, { once: true, passive: true });
|
||||
@ -1,12 +0,0 @@
|
||||
// Define global chrome object to be compliant with the extension code
|
||||
global.chrome = {
|
||||
runtime: {
|
||||
getManifest: () => ({
|
||||
version: 1
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () => {
|
||||
require("YoutubeNonStop/autoconfirm.js");
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
const { injectCSS } = require("../utils");
|
||||
const path = require("path");
|
||||
const { injectCSS } = require("../utils");
|
||||
|
||||
module.exports = win => {
|
||||
injectCSS(win.webContents, path.join(__dirname, "style.css"));
|
||||
10
plugins/blur-nav-bar/style.css
Normal file
10
plugins/blur-nav-bar/style.css
Normal file
@ -0,0 +1,10 @@
|
||||
#nav-bar-background,
|
||||
#header.ytmusic-item-section-renderer,
|
||||
ytmusic-tabs {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
}
|
||||
|
||||
#nav-bar-divider {
|
||||
display: none !important;
|
||||
}
|
||||
14
plugins/disable-autoplay/front.js
Normal file
14
plugins/disable-autoplay/front.js
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = () => {
|
||||
document.addEventListener('apiLoaded', apiEvent => {
|
||||
apiEvent.detail.addEventListener('videodatachange', name => {
|
||||
if (name === 'dataloaded') {
|
||||
apiEvent.detail.pauseVideo();
|
||||
document.querySelector('video').ontimeupdate = e => {
|
||||
e.target.pause();
|
||||
}
|
||||
} else {
|
||||
document.querySelector('video').ontimeupdate = null;
|
||||
}
|
||||
})
|
||||
}, { once: true, passive: true })
|
||||
};
|
||||
@ -1,51 +1,148 @@
|
||||
const Discord = require("discord-rpc");
|
||||
const { dev } = require("electron-is");
|
||||
const { dialog, app } = require("electron");
|
||||
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
|
||||
const rpc = new Discord.Client({
|
||||
transport: "ipc",
|
||||
});
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
|
||||
// Application ID registered by @semvis123
|
||||
const clientId = "790655993809338398";
|
||||
|
||||
module.exports = (win) => {
|
||||
const registerCallback = getSongInfo(win);
|
||||
/**
|
||||
* @typedef {Object} Info
|
||||
* @property {import('discord-rpc').Client} rpc
|
||||
* @property {boolean} ready
|
||||
* @property {import('../../providers/song-info').SongInfo} lastSongInfo
|
||||
*/
|
||||
/**
|
||||
* @type {Info}
|
||||
*/
|
||||
const info = {
|
||||
rpc: null,
|
||||
ready: false,
|
||||
lastSongInfo: null,
|
||||
};
|
||||
/**
|
||||
* @type {(() => void)[]}
|
||||
*/
|
||||
const refreshCallbacks = [];
|
||||
const resetInfo = () => {
|
||||
info.rpc = null;
|
||||
info.ready = false;
|
||||
clearTimeout(clearActivity);
|
||||
if (dev()) console.log("discord disconnected");
|
||||
refreshCallbacks.forEach(cb => cb());
|
||||
};
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.on("ready-to-show", () => {
|
||||
rpc.on("ready", () => {
|
||||
// Register the callback
|
||||
registerCallback((songInfo) => {
|
||||
// Song information changed, so lets update the rich presence
|
||||
const activityInfo = {
|
||||
details: songInfo.title,
|
||||
state: songInfo.artist,
|
||||
largeImageKey: "logo",
|
||||
largeImageText: songInfo.views + " - " + songInfo.likes,
|
||||
};
|
||||
let window;
|
||||
const connect = (showErr = false) => {
|
||||
if (info.rpc) {
|
||||
if (dev())
|
||||
console.log('Attempted to connect with active RPC object');
|
||||
return;
|
||||
}
|
||||
|
||||
if (songInfo.isPaused) {
|
||||
// Add an idle icon to show that the song is paused
|
||||
activityInfo.smallImageKey = "idle";
|
||||
activityInfo.smallImageText = "idle/paused";
|
||||
} else {
|
||||
// Add the start and end time of the song
|
||||
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
|
||||
activityInfo.startTimestamp = songStartTime;
|
||||
activityInfo.endTimestamp =
|
||||
songStartTime + songInfo.songDuration * 1000;
|
||||
}
|
||||
info.rpc = new Discord.Client({
|
||||
transport: "ipc",
|
||||
});
|
||||
info.ready = false;
|
||||
|
||||
rpc.setActivity(activityInfo);
|
||||
});
|
||||
});
|
||||
info.rpc.once("connected", () => {
|
||||
if (dev()) console.log("discord connected");
|
||||
refreshCallbacks.forEach(cb => cb());
|
||||
});
|
||||
info.rpc.once("ready", () => {
|
||||
info.ready = true;
|
||||
if (info.lastSongInfo) updateActivity(info.lastSongInfo)
|
||||
});
|
||||
info.rpc.once("disconnected", resetInfo);
|
||||
|
||||
// Startup the rpc client
|
||||
rpc
|
||||
.login({
|
||||
clientId,
|
||||
})
|
||||
.catch(console.error);
|
||||
// Startup the rpc client
|
||||
info.rpc.login({ clientId }).catch(err => {
|
||||
resetInfo();
|
||||
if (dev()) console.error(err);
|
||||
if (showErr) dialog.showMessageBox(window, { title: 'Connection failed', message: err.message || String(err), type: 'error' });
|
||||
});
|
||||
};
|
||||
|
||||
let clearActivity;
|
||||
/**
|
||||
* @type {import('../../providers/song-info').songInfoCallback}
|
||||
*/
|
||||
let updateActivity;
|
||||
|
||||
module.exports = (win, { activityTimoutEnabled, activityTimoutTime, listenAlong }) => {
|
||||
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 && activityTimoutEnabled && activityTimoutTime === 0) {
|
||||
info.rpc.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 = {
|
||||
type: 2, // Listening, addressed in https://github.com/discordjs/RPC/pull/149
|
||||
details: songInfo.title,
|
||||
state: songInfo.artist,
|
||||
largeImageKey: songInfo.imageSrc,
|
||||
largeImageText: [
|
||||
songInfo.uploadDate,
|
||||
songInfo.views.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + " views",
|
||||
].join(' || '),
|
||||
buttons: listenAlong ? [
|
||||
{ label: "Listen Along", url: songInfo.url },
|
||||
] : undefined,
|
||||
};
|
||||
|
||||
if (songInfo.isPaused) {
|
||||
// Add an idle icon to show that the song is paused
|
||||
activityInfo.smallImageKey = "idle";
|
||||
activityInfo.smallImageText = "idle/paused";
|
||||
// Set start the timer so the activity gets cleared after a while if enabled
|
||||
if (activityTimoutEnabled)
|
||||
clearActivity = setTimeout(() => info.rpc.clearActivity().catch(console.error), activityTimoutTime ?? 10000);
|
||||
} else {
|
||||
// Add the start and end time of the song
|
||||
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
|
||||
activityInfo.startTimestamp = songStartTime;
|
||||
activityInfo.endTimestamp =
|
||||
songStartTime + songInfo.songDuration * 1000;
|
||||
}
|
||||
|
||||
info.rpc.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', module.exports.clear)
|
||||
};
|
||||
|
||||
module.exports.clear = () => {
|
||||
if (info.rpc) info.rpc.clearActivity();
|
||||
clearTimeout(clearActivity);
|
||||
};
|
||||
module.exports.connect = connect;
|
||||
module.exports.registerRefresh = (cb) => refreshCallbacks.push(cb);
|
||||
module.exports.isConnected = () => info.rpc !== null;
|
||||
|
||||
47
plugins/discord/menu.js
Normal file
47
plugins/discord/menu.js
Normal file
@ -0,0 +1,47 @@
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const { edit } = require("../../config");
|
||||
const { clear, connect, registerRefresh, isConnected } = require("./back");
|
||||
|
||||
let hasRegisterred = false;
|
||||
|
||||
module.exports = (win, options, refreshMenu) => {
|
||||
if (!hasRegisterred) {
|
||||
registerRefresh(refreshMenu);
|
||||
hasRegisterred = true;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: isConnected() ? "Connected" : "Reconnect",
|
||||
enabled: !isConnected(),
|
||||
click: connect,
|
||||
},
|
||||
{
|
||||
label: "Clear activity",
|
||||
click: clear,
|
||||
},
|
||||
{
|
||||
label: "Clear activity after timeout",
|
||||
type: "checkbox",
|
||||
checked: options.activityTimoutEnabled,
|
||||
click: (item) => {
|
||||
options.activityTimoutEnabled = item.checked;
|
||||
setOptions('discord', options);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Listen Along",
|
||||
type: "checkbox",
|
||||
checked: options.listenAlong,
|
||||
click: (item) => {
|
||||
options.listenAlong = item.checked;
|
||||
setOptions('discord', options);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Set timeout time in config",
|
||||
// open config.json
|
||||
click: edit,
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -2,6 +2,7 @@ const CHANNEL = "downloader";
|
||||
const ACTIONS = {
|
||||
ERROR: "error",
|
||||
METADATA: "metadata",
|
||||
PROGRESS: "progress",
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@ -1,46 +1,87 @@
|
||||
const { writeFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
|
||||
const { dialog } = require("electron");
|
||||
const ID3Writer = require("browser-id3-writer");
|
||||
const { dialog, ipcMain } = require("electron");
|
||||
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
const { injectCSS, listenAction } = require("../utils");
|
||||
const { cropMaxWidth } = require("./utils");
|
||||
const { ACTIONS, CHANNEL } = require("./actions.js");
|
||||
const { getImage } = require("../../providers/song-info");
|
||||
|
||||
const sendError = (win, err) => {
|
||||
const dialogOpts = {
|
||||
const sendError = (win, error) => {
|
||||
win.setProgressBar(-1); // close progress bar
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
buttons: ["OK"],
|
||||
title: "Error in download!",
|
||||
message: "Argh! Apologies, download failed…",
|
||||
detail: err.toString(),
|
||||
};
|
||||
dialog.showMessageBox(dialogOpts);
|
||||
detail: error.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
let metadata = {};
|
||||
let nowPlayingMetadata = {};
|
||||
|
||||
function handle(win) {
|
||||
injectCSS(win.webContents, join(__dirname, "style.css"));
|
||||
const registerCallback = getSongInfo(win);
|
||||
registerCallback((info) => {
|
||||
metadata = {
|
||||
...info,
|
||||
image: info.image ? info.image.toDataURL() : undefined,
|
||||
};
|
||||
nowPlayingMetadata = info;
|
||||
});
|
||||
|
||||
listenAction(CHANNEL, (event, action, error) => {
|
||||
listenAction(CHANNEL, (event, action, arg) => {
|
||||
switch (action) {
|
||||
case ACTIONS.ERROR:
|
||||
sendError(win, error);
|
||||
case ACTIONS.ERROR: // arg = error
|
||||
sendError(win, arg);
|
||||
break;
|
||||
case ACTIONS.METADATA:
|
||||
event.returnValue = JSON.stringify(metadata);
|
||||
event.returnValue = JSON.stringify(nowPlayingMetadata);
|
||||
break;
|
||||
case ACTIONS.PROGRESS: // arg = progress
|
||||
win.setProgressBar(arg);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown action: " + action);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("add-metadata", async (event, filePath, songBuffer, currentMetadata) => {
|
||||
let fileBuffer = songBuffer;
|
||||
const songMetadata = currentMetadata.imageSrcYTPL ? // This means metadata come from ytpl.getInfo();
|
||||
{
|
||||
...currentMetadata,
|
||||
image: cropMaxWidth(await getImage(currentMetadata.imageSrcYTPL))
|
||||
} :
|
||||
{ ...nowPlayingMetadata, ...currentMetadata };
|
||||
|
||||
try {
|
||||
const coverBuffer = songMetadata.image && !songMetadata.image.isEmpty() ?
|
||||
songMetadata.image.toPNG() : null;
|
||||
|
||||
const writer = new ID3Writer(songBuffer);
|
||||
|
||||
// Create the metadata tags
|
||||
writer
|
||||
.setFrame("TIT2", songMetadata.title)
|
||||
.setFrame("TPE1", [songMetadata.artist]);
|
||||
if (coverBuffer) {
|
||||
writer.setFrame("APIC", {
|
||||
type: 3,
|
||||
data: coverBuffer,
|
||||
description: ""
|
||||
});
|
||||
}
|
||||
writer.addTag();
|
||||
fileBuffer = Buffer.from(writer.arrayBuffer);
|
||||
} catch (error) {
|
||||
sendError(win, error);
|
||||
}
|
||||
|
||||
writeFileSync(filePath, fileBuffer);
|
||||
// Notify the youtube-dl file
|
||||
event.reply("add-metadata-done");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = handle;
|
||||
module.exports.sendError = sendError;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
const { contextBridge } = require("electron");
|
||||
|
||||
const { defaultConfig } = require("../../config");
|
||||
const { getSongMenu } = require("../../providers/dom-elements");
|
||||
const { ElementFromFile, templatePath, triggerAction } = require("../utils");
|
||||
const { ACTIONS, CHANNEL } = require("./actions.js");
|
||||
const { downloadVideoToMP3 } = require("./youtube-dl");
|
||||
@ -13,7 +15,7 @@ let pluginOptions = {};
|
||||
|
||||
const observer = new MutationObserver((mutations, observer) => {
|
||||
if (!menu) {
|
||||
menu = document.querySelector("ytmusic-menu-popup-renderer paper-listbox");
|
||||
menu = getSongMenu();
|
||||
}
|
||||
|
||||
if (menu && !menu.contains(downloadButton)) {
|
||||
@ -23,6 +25,7 @@ const observer = new MutationObserver((mutations, observer) => {
|
||||
});
|
||||
|
||||
const reinit = () => {
|
||||
triggerAction(CHANNEL, ACTIONS.PROGRESS, -1); // closes progress bar
|
||||
if (!progress) {
|
||||
console.warn("Cannot update progress");
|
||||
} else {
|
||||
@ -30,27 +33,45 @@ const reinit = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = defaultConfig.url;
|
||||
|
||||
// TODO: re-enable once contextIsolation is set to true
|
||||
// contextBridge.exposeInMainWorld("downloader", {
|
||||
// download: () => {
|
||||
global.download = () => {
|
||||
const videoUrl = window.location.href;
|
||||
triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar
|
||||
let metadata;
|
||||
let videoUrl = getSongMenu()
|
||||
// selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer.iron-selected[tabindex="0"] #navigation-endpoint')
|
||||
?.getAttribute("href");
|
||||
if (videoUrl) {
|
||||
videoUrl = baseUrl + "/" + videoUrl;
|
||||
metadata = null;
|
||||
} else {
|
||||
metadata = global.songInfo;
|
||||
videoUrl = metadata.url || window.location.href;
|
||||
}
|
||||
|
||||
downloadVideoToMP3(
|
||||
videoUrl,
|
||||
(feedback) => {
|
||||
(feedback, ratio = undefined) => {
|
||||
if (!progress) {
|
||||
console.warn("Cannot update progress");
|
||||
} else {
|
||||
progress.innerHTML = feedback;
|
||||
}
|
||||
if (ratio) {
|
||||
triggerAction(CHANNEL, ACTIONS.PROGRESS, ratio);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
triggerAction(CHANNEL, ACTIONS.ERROR, error);
|
||||
reinit();
|
||||
},
|
||||
reinit,
|
||||
pluginOptions
|
||||
pluginOptions,
|
||||
metadata
|
||||
);
|
||||
};
|
||||
// });
|
||||
|
||||
125
plugins/downloader/menu.js
Normal file
125
plugins/downloader/menu.js
Normal file
@ -0,0 +1,125 @@
|
||||
const { existsSync, mkdirSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
|
||||
const { dialog, ipcMain } = require("electron");
|
||||
const is = require("electron-is");
|
||||
const ytpl = require("ytpl");
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const { sendError } = require("./back");
|
||||
const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils");
|
||||
|
||||
let downloadLabel = defaultMenuDownloadLabel;
|
||||
let playingPlaylistId = undefined;
|
||||
let callbackIsRegistered = false;
|
||||
|
||||
module.exports = (win, options) => {
|
||||
if (!callbackIsRegistered) {
|
||||
ipcMain.on("video-src-changed", async (_, data) => {
|
||||
playingPlaylistId = JSON.parse(data)?.videoDetails?.playlistId;
|
||||
});
|
||||
callbackIsRegistered = true;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: downloadLabel,
|
||||
click: async () => {
|
||||
const currentPagePlaylistId = new URL(win.webContents.getURL()).searchParams.get("list");
|
||||
const playlistId = currentPagePlaylistId || playingPlaylistId;
|
||||
if (!playlistId) {
|
||||
sendError(win, new Error("No playlist ID found"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
||||
let playlist;
|
||||
try {
|
||||
playlist = await ytpl(playlistId, {
|
||||
limit: options.playlistMaxItems || Infinity,
|
||||
});
|
||||
} catch (e) {
|
||||
sendError(win, e);
|
||||
return;
|
||||
}
|
||||
const playlistTitle = playlist.title;
|
||||
|
||||
const folder = getFolder(options.downloadFolder);
|
||||
const playlistFolder = join(folder, playlistTitle);
|
||||
if (existsSync(playlistFolder)) {
|
||||
sendError(
|
||||
win,
|
||||
new Error(`The folder ${playlistFolder} already exists`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
mkdirSync(playlistFolder, { recursive: true });
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
buttons: ["OK"],
|
||||
title: "Started Download",
|
||||
message: `Downloading Playlist "${playlistTitle}"`,
|
||||
detail: `(${playlist.items.length} songs)`,
|
||||
});
|
||||
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Downloading playlist "${playlistTitle}" (${playlist.items.length} songs)`
|
||||
);
|
||||
}
|
||||
|
||||
const steps = 1 / playlist.items.length;
|
||||
let progress = 0;
|
||||
|
||||
win.setProgressBar(2); // starts with indefinite bar
|
||||
|
||||
let dirWatcher = chokidar.watch(playlistFolder);
|
||||
dirWatcher.on('add', () => {
|
||||
progress += steps;
|
||||
if (progress >= 0.9999) {
|
||||
win.setProgressBar(-1); // close progress bar
|
||||
dirWatcher.close().then(() => dirWatcher = null);
|
||||
} else {
|
||||
win.setProgressBar(progress);
|
||||
}
|
||||
});
|
||||
|
||||
playlist.items.forEach((song) => {
|
||||
win.webContents.send(
|
||||
"downloader-download-playlist",
|
||||
song.url,
|
||||
playlistTitle,
|
||||
options
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Choose download folder",
|
||||
click: () => {
|
||||
let result = dialog.showOpenDialogSync({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
defaultPath: getFolder(options.downloadFolder),
|
||||
});
|
||||
if (result) {
|
||||
options.downloadFolder = result[0];
|
||||
setOptions("downloader", options);
|
||||
} // else = user pressed cancel
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Presets",
|
||||
submenu: Object.keys(presets).map((preset) => ({
|
||||
label: preset,
|
||||
type: "radio",
|
||||
click: () => {
|
||||
options.preset = preset;
|
||||
setOptions("downloader", options);
|
||||
},
|
||||
checked: options.preset === preset || presets[preset] === undefined,
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -6,8 +6,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item > .yt-simple-endpoint:hover {
|
||||
background-color: var(--ytmusic-menu-item-hover-background-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
flex: var(--ytmusic-menu-item-icon_-_flex);
|
||||
margin: var(--ytmusic-menu-item-icon_-_margin);
|
||||
fill: var(--ytmusic-menu-item-icon_-_fill);
|
||||
stroke: var(--iron-icon-stroke-color, none);
|
||||
width: var(--iron-icon-width, 24px);
|
||||
height: var(--iron-icon-height, 24px);
|
||||
animation: var(--iron-icon_-_animation);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="menu-item ytmusic-menu-popup-renderer"
|
||||
class="style-scope menu-item ytmusic-menu-popup-renderer"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-disabled="false"
|
||||
@ -7,31 +7,39 @@
|
||||
onclick="download()"
|
||||
>
|
||||
<div
|
||||
class="menu-icon yt-icon-container yt-icon ytmusic-toggle-menu-service-item-renderer"
|
||||
id="navigation-endpoint"
|
||||
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
focusable="false"
|
||||
class="style-scope yt-icon"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%;"
|
||||
<div
|
||||
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
|
||||
>
|
||||
<g class="style-scope yt-icon">
|
||||
<path
|
||||
d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
|
||||
class="style-scope yt-icon"
|
||||
/>
|
||||
<path
|
||||
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
|
||||
class="style-scope yt-icon"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="text style-scope ytmusic-toggle-menu-service-item-renderer"
|
||||
id="ytmcustom-download"
|
||||
>
|
||||
Download
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
focusable="false"
|
||||
class="style-scope yt-icon"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
||||
>
|
||||
<g class="style-scope yt-icon">
|
||||
<path
|
||||
d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
|
||||
class="style-scope yt-icon"
|
||||
fill="#aaaaaa"
|
||||
/>
|
||||
<path
|
||||
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
|
||||
class="style-scope yt-icon"
|
||||
fill="#aaaaaa"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="text style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="ytmcustom-download"
|
||||
>
|
||||
Download
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
plugins/downloader/utils.js
Normal file
39
plugins/downloader/utils.js
Normal file
@ -0,0 +1,39 @@
|
||||
const electron = require("electron");
|
||||
|
||||
module.exports.getFolder = customFolder => customFolder || electron.app.getPath("downloads");
|
||||
module.exports.defaultMenuDownloadLabel = "Download playlist";
|
||||
|
||||
const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"];
|
||||
module.exports.urlToJPG = (imgUrl, videoId) => {
|
||||
if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl;
|
||||
//it will almost never get further than hqdefault
|
||||
for (const quality of orderedQualityList) {
|
||||
if (imgUrl.includes(quality)) {
|
||||
return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`;
|
||||
}
|
||||
}
|
||||
return `https://img.youtube.com/vi/${videoId}/default.jpg`;
|
||||
}
|
||||
|
||||
module.exports.cropMaxWidth = (image) => {
|
||||
const imageSize = image.getSize();
|
||||
// standart youtube artwork width with margins from both sides is 280 + 720 + 280
|
||||
if (imageSize.width === 1280 && imageSize.height === 720) {
|
||||
return image.crop({
|
||||
x: 280,
|
||||
y: 0,
|
||||
width: 720,
|
||||
height: 720
|
||||
});
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
// Presets for FFmpeg
|
||||
module.exports.presets = {
|
||||
"None (defaults to mp3)": undefined,
|
||||
opus: {
|
||||
extension: "opus",
|
||||
ffmpegArgs: ["-acodec", "libopus"],
|
||||
},
|
||||
};
|
||||
@ -1,8 +1,9 @@
|
||||
const { randomBytes } = require("crypto");
|
||||
const { writeFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
|
||||
const downloadsFolder = require("downloads-folder");
|
||||
const Mutex = require("async-mutex").Mutex;
|
||||
const { ipcRenderer } = require("electron");
|
||||
const remote = require('@electron/remote');
|
||||
const is = require("electron-is");
|
||||
const filenamify = require("filenamify");
|
||||
|
||||
@ -12,8 +13,10 @@ const filenamify = require("filenamify");
|
||||
const FFmpeg = require("@ffmpeg/ffmpeg/dist/ffmpeg.min");
|
||||
const ytdl = require("ytdl-core");
|
||||
|
||||
const { triggerActionSync } = require("../utils");
|
||||
const { triggerAction, triggerActionSync } = require("../utils");
|
||||
const { ACTIONS, CHANNEL } = require("./actions.js");
|
||||
const { presets, urlToJPG } = require("./utils");
|
||||
const { cleanupName } = require("../../providers/song-info");
|
||||
|
||||
const { createFFmpeg } = FFmpeg;
|
||||
const ffmpeg = createFFmpeg({
|
||||
@ -21,16 +24,34 @@ const ffmpeg = createFFmpeg({
|
||||
logger: () => {}, // console.log,
|
||||
progress: () => {}, // console.log,
|
||||
});
|
||||
const ffmpegMutex = new Mutex();
|
||||
|
||||
const downloadVideoToMP3 = (
|
||||
const downloadVideoToMP3 = async (
|
||||
videoUrl,
|
||||
sendFeedback,
|
||||
sendError,
|
||||
reinit,
|
||||
options
|
||||
options,
|
||||
metadata = undefined,
|
||||
subfolder = ""
|
||||
) => {
|
||||
sendFeedback("Downloading…");
|
||||
|
||||
if (metadata === null) {
|
||||
const { videoDetails } = await ytdl.getInfo(videoUrl);
|
||||
const thumbnails = videoDetails?.thumbnails;
|
||||
metadata = {
|
||||
artist:
|
||||
videoDetails?.media?.artist ||
|
||||
cleanupName(videoDetails?.author?.name) ||
|
||||
"",
|
||||
title: videoDetails?.media?.song || videoDetails?.title || "",
|
||||
imageSrcYTPL: thumbnails ?
|
||||
urlToJPG(thumbnails[thumbnails.length - 1].url, videoDetails?.videoId)
|
||||
: ""
|
||||
}
|
||||
}
|
||||
|
||||
let videoName = "YouTube Music - Unknown title";
|
||||
let videoReadableStream;
|
||||
try {
|
||||
@ -50,9 +71,10 @@ const downloadVideoToMP3 = (
|
||||
.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
})
|
||||
.on("progress", (chunkLength, downloaded, total) => {
|
||||
const progress = Math.floor((downloaded / total) * 100);
|
||||
sendFeedback("Download: " + progress + "%");
|
||||
.on("progress", (_chunkLength, downloaded, total) => {
|
||||
const ratio = downloaded / total;
|
||||
const progress = Math.floor(ratio * 100);
|
||||
sendFeedback("Download: " + progress + "%", ratio);
|
||||
})
|
||||
.on("info", (info, format) => {
|
||||
videoName = info.videoDetails.title.replace("|", "").toString("ascii");
|
||||
@ -66,9 +88,18 @@ const downloadVideoToMP3 = (
|
||||
}
|
||||
})
|
||||
.on("error", sendError)
|
||||
.on("end", () => {
|
||||
.on("end", async () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
toMP3(videoName, buffer, sendFeedback, sendError, reinit, options);
|
||||
await toMP3(
|
||||
videoName,
|
||||
buffer,
|
||||
sendFeedback,
|
||||
sendError,
|
||||
reinit,
|
||||
options,
|
||||
metadata,
|
||||
subfolder
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@ -78,14 +109,18 @@ const toMP3 = async (
|
||||
sendFeedback,
|
||||
sendError,
|
||||
reinit,
|
||||
options
|
||||
options,
|
||||
existingMetadata = undefined,
|
||||
subfolder = ""
|
||||
) => {
|
||||
const convertOptions = { ...presets[options.preset], ...options };
|
||||
const safeVideoName = randomBytes(32).toString("hex");
|
||||
const extension = options.extension || "mp3";
|
||||
const extension = convertOptions.extension || "mp3";
|
||||
const releaseFFmpegMutex = await ffmpegMutex.acquire();
|
||||
|
||||
try {
|
||||
if (!ffmpeg.isLoaded()) {
|
||||
sendFeedback("Loading…");
|
||||
sendFeedback("Loading…", 2); // indefinite progress bar after download
|
||||
await ffmpeg.load();
|
||||
}
|
||||
|
||||
@ -93,30 +128,39 @@ const toMP3 = async (
|
||||
ffmpeg.FS("writeFile", safeVideoName, buffer);
|
||||
|
||||
sendFeedback("Converting…");
|
||||
const metadata = getMetadata();
|
||||
const metadata = existingMetadata || getMetadata();
|
||||
await ffmpeg.run(
|
||||
"-i",
|
||||
safeVideoName,
|
||||
...getFFmpegMetadataArgs(metadata),
|
||||
...(options.ffmpegArgs || []),
|
||||
...(convertOptions.ffmpegArgs || []),
|
||||
safeVideoName + "." + extension
|
||||
);
|
||||
|
||||
const folder = options.downloadFolder || downloadsFolder();
|
||||
const name = metadata
|
||||
? `${metadata.artist} - ${metadata.title}`
|
||||
const folder = options.downloadFolder || remote.app.getPath("downloads");
|
||||
const name = metadata.title
|
||||
? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}`
|
||||
: videoName;
|
||||
const filename = filenamify(name + "." + extension, {
|
||||
replacement: "_",
|
||||
maxLength: 255,
|
||||
});
|
||||
writeFileSync(
|
||||
join(folder, filename),
|
||||
ffmpeg.FS("readFile", safeVideoName + "." + extension)
|
||||
);
|
||||
|
||||
reinit();
|
||||
const filePath = join(folder, subfolder, filename);
|
||||
const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension);
|
||||
|
||||
// Add the metadata
|
||||
sendFeedback("Adding metadata…");
|
||||
ipcRenderer.send("add-metadata", filePath, fileBuffer, {
|
||||
artist: metadata.artist,
|
||||
title: metadata.title,
|
||||
imageSrcYTPL: metadata.imageSrcYTPL
|
||||
});
|
||||
ipcRenderer.once("add-metadata-done", reinit);
|
||||
} catch (e) {
|
||||
sendError(e);
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
};
|
||||
|
||||
@ -130,13 +174,28 @@ const getFFmpegMetadataArgs = (metadata) => {
|
||||
}
|
||||
|
||||
return [
|
||||
"-metadata",
|
||||
`title=${metadata.title}`,
|
||||
"-metadata",
|
||||
`artist=${metadata.artist}`,
|
||||
...(metadata.title ? ["-metadata", `title=${metadata.title}`] : []),
|
||||
...(metadata.artist ? ["-metadata", `artist=${metadata.artist}`] : []),
|
||||
];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
downloadVideoToMP3,
|
||||
};
|
||||
|
||||
ipcRenderer.on(
|
||||
"downloader-download-playlist",
|
||||
(_, url, playlistFolder, options) => {
|
||||
downloadVideoToMP3(
|
||||
url,
|
||||
() => {},
|
||||
(error) => {
|
||||
triggerAction(CHANNEL, ACTIONS.ERROR, error);
|
||||
},
|
||||
() => {},
|
||||
options,
|
||||
null,
|
||||
playlistFolder
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
47
plugins/exponential-volume/front.js
Normal file
47
plugins/exponential-volume/front.js
Normal file
@ -0,0 +1,47 @@
|
||||
// "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();
|
||||
const { get, set } = Object.getOwnPropertyDescriptor(
|
||||
HTMLMediaElement.prototype,
|
||||
"volume"
|
||||
);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "volume", {
|
||||
get() {
|
||||
const lowVolume = get.call(this);
|
||||
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);
|
||||
const storedDeviation = Math.abs(
|
||||
storedOriginalVolume - calculatedOriginalVolume
|
||||
);
|
||||
|
||||
const originalVolume =
|
||||
storedDeviation < 0.01
|
||||
? storedOriginalVolume
|
||||
: calculatedOriginalVolume;
|
||||
return originalVolume;
|
||||
},
|
||||
set(originalVolume) {
|
||||
const lowVolume = originalVolume ** EXPONENT;
|
||||
storedOriginalVolumes.set(this, originalVolume);
|
||||
set.call(this, lowVolume);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
document.addEventListener("apiLoaded", exponentialVolume, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
33
plugins/in-app-menu/back.js
Normal file
33
plugins/in-app-menu/back.js
Normal file
@ -0,0 +1,33 @@
|
||||
const path = require("path");
|
||||
|
||||
const electronLocalshortcut = require("electron-localshortcut");
|
||||
|
||||
const config = require("../../config");
|
||||
const { injectCSS } = require("../utils");
|
||||
|
||||
//tracks menu visibility
|
||||
let visible = true;
|
||||
|
||||
module.exports = (win) => {
|
||||
// css for custom scrollbar + disable drag area(was causing bugs)
|
||||
injectCSS(win.webContents, path.join(__dirname, "style.css"));
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
//register keyboard shortcut && hide menu if hideMenu is enabled
|
||||
if (config.get("options.hideMenu")) {
|
||||
electronLocalshortcut.register(win, "Esc", () => {
|
||||
setMenuVisibility(!visible);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
// fix bug with menu not applying on start when no internet connection available
|
||||
setMenuVisibility(!config.get("options.hideMenu"));
|
||||
});
|
||||
|
||||
function setMenuVisibility(value) {
|
||||
visible = value;
|
||||
win.webContents.send("updateMenu", visible);
|
||||
}
|
||||
};
|
||||
33
plugins/in-app-menu/front.js
Normal file
33
plugins/in-app-menu/front.js
Normal file
@ -0,0 +1,33 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const { Menu } = require("@electron/remote");
|
||||
|
||||
|
||||
const customTitlebar = require("custom-electron-titlebar");
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
|
||||
module.exports = () => {
|
||||
const bar = new customTitlebar.Titlebar({
|
||||
backgroundColor: customTitlebar.Color.fromHex("#050505"),
|
||||
itemBackgroundColor: customTitlebar.Color.fromHex("#121212"),
|
||||
});
|
||||
bar.updateTitle(" ");
|
||||
document.title = "Youtube Music";
|
||||
|
||||
ipcRenderer.on("updateMenu", function (_event, showMenu) {
|
||||
bar.updateMenu(showMenu ? Menu.getApplicationMenu() : null);
|
||||
});
|
||||
|
||||
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
setNavbarMargin();
|
||||
const playPageObserver = new MutationObserver(setNavbarMargin);
|
||||
playPageObserver.observe($('ytmusic-app-layout'), { attributeFilter: ['player-page-open_', 'playerPageOpen_'] })
|
||||
}, { once: true, passive: true })
|
||||
};
|
||||
|
||||
function setNavbarMargin() {
|
||||
$('#nav-bar-background').style.right =
|
||||
$('ytmusic-app-layout').playerPageOpen_ ?
|
||||
'0px' :
|
||||
'12px';
|
||||
}
|
||||
73
plugins/in-app-menu/style.css
Normal file
73
plugins/in-app-menu/style.css
Normal file
@ -0,0 +1,73 @@
|
||||
/* increase font size for menu and menuItems */
|
||||
.titlebar,
|
||||
.menubar-menu-container .action-label {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* fixes nav-bar-background opacity bug, reposition it, and allows clicking scrollbar through it */
|
||||
#nav-bar-background {
|
||||
opacity: 1 !important;
|
||||
pointer-events: none !important;
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
height: 75px !important;
|
||||
}
|
||||
|
||||
/* remove window dragging for nav bar (conflict with titlebar drag) */
|
||||
ytmusic-nav-bar,
|
||||
.tab-titleiron-icon,
|
||||
ytmusic-pivot-bar-item-renderer {
|
||||
-webkit-app-region: unset !important;
|
||||
}
|
||||
|
||||
/* move up item selection renderers */
|
||||
ytmusic-item-section-renderer.stuck #header.ytmusic-item-section-renderer,
|
||||
ytmusic-tabs.stuck {
|
||||
top: calc(var(--ytmusic-nav-bar-height) - 15px) !important;
|
||||
}
|
||||
|
||||
/* fix weird positioning in search screen*/
|
||||
ytmusic-header-renderer.ytmusic-search-page {
|
||||
position: unset !important;
|
||||
}
|
||||
|
||||
/* Move navBar downwards */
|
||||
ytmusic-nav-bar[slot="nav-bar"] {
|
||||
top: 17px !important;
|
||||
}
|
||||
|
||||
/* fix page progress bar position*/
|
||||
yt-page-navigation-progress,
|
||||
#progress.yt-page-navigation-progress {
|
||||
top: 30px !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
background-color: #030303;
|
||||
border-radius: 100px;
|
||||
-moz-border-radius: 100px;
|
||||
-webkit-border-radius: 100px;
|
||||
}
|
||||
/* hover effect for both scrollbar area, and scrollbar 'thumb' */
|
||||
::-webkit-scrollbar:hover {
|
||||
background-color: rgba(15, 15, 15, 0.699);
|
||||
}
|
||||
|
||||
/* The scrollbar 'thumb' ...that marque oval shape in a scrollbar */
|
||||
::-webkit-scrollbar-thumb:vertical {
|
||||
background-clip: padding-box;
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
|
||||
background: #3a3a3a;
|
||||
border-radius: 100px;
|
||||
-moz-border-radius: 100px;
|
||||
-webkit-border-radius: 100px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:vertical:active {
|
||||
background: #4d4c4c; /* Some darker color when you click it */
|
||||
border-radius: 100px;
|
||||
-moz-border-radius: 100px;
|
||||
-webkit-border-radius: 100px;
|
||||
}
|
||||
160
plugins/last-fm/back.js
Normal file
160
plugins/last-fm/back.js
Normal file
@ -0,0 +1,160 @@
|
||||
const fetch = require('node-fetch');
|
||||
const md5 = require('md5');
|
||||
const { shell } = require('electron');
|
||||
const { setOptions } = require('../../config/plugins');
|
||||
const registerCallback = require('../../providers/song-info');
|
||||
const defaultConfig = require('../../config/defaults');
|
||||
|
||||
const createFormData = params => {
|
||||
// creates the body for in the post request
|
||||
const formData = new URLSearchParams();
|
||||
for (const key in params) {
|
||||
formData.append(key, params[key]);
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
const createQueryString = (params, api_sig) => {
|
||||
// creates a querystring
|
||||
const queryData = [];
|
||||
params.api_sig = api_sig;
|
||||
for (const key in params) {
|
||||
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`);
|
||||
}
|
||||
return '?'+queryData.join('&');
|
||||
}
|
||||
|
||||
const createApiSig = (params, secret) => {
|
||||
// this function creates the api signature, see: https://www.last.fm/api/authspec
|
||||
const keys = [];
|
||||
for (const key in params) {
|
||||
keys.push(key);
|
||||
}
|
||||
keys.sort();
|
||||
let sig = '';
|
||||
for (const key of keys) {
|
||||
if (String(key) === 'format')
|
||||
continue
|
||||
sig += `${key}${params[key]}`;
|
||||
}
|
||||
sig += secret;
|
||||
sig = md5(sig);
|
||||
return sig;
|
||||
}
|
||||
|
||||
const createToken = async ({ api_key, api_root, secret }) => {
|
||||
// creates and stores the auth token
|
||||
const data = {
|
||||
method: 'auth.gettoken',
|
||||
api_key: api_key,
|
||||
format: 'json'
|
||||
};
|
||||
const api_sig = createApiSig(data, secret);
|
||||
let response = await fetch(`${api_root}${createQueryString(data, api_sig)}`);
|
||||
response = await response.json();
|
||||
return response?.token;
|
||||
}
|
||||
|
||||
const authenticate = async config => {
|
||||
// asks the user for authentication
|
||||
config.token = await createToken(config);
|
||||
setOptions('last-fm', config);
|
||||
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
const getAndSetSessionKey = async config => {
|
||||
// get and store the session key
|
||||
const data = {
|
||||
api_key: config.api_key,
|
||||
format: 'json',
|
||||
method: 'auth.getsession',
|
||||
token: config.token,
|
||||
};
|
||||
const api_sig = createApiSig(data, config.secret);
|
||||
let res = await fetch(`${config.api_root}${createQueryString(data, api_sig)}`);
|
||||
res = await res.json();
|
||||
if (res.error)
|
||||
await authenticate(config);
|
||||
config.session_key = res?.session?.key;
|
||||
setOptions('last-fm', config);
|
||||
return config;
|
||||
}
|
||||
|
||||
const postSongDataToAPI = async (songInfo, config, data) => {
|
||||
// this sends a post request to the api, and adds the common data
|
||||
if (!config.session_key)
|
||||
await getAndSetSessionKey(config);
|
||||
|
||||
const postData = {
|
||||
track: songInfo.title,
|
||||
duration: songInfo.songDuration,
|
||||
artist: songInfo.artist,
|
||||
api_key: config.api_key,
|
||||
sk: config.session_key,
|
||||
format: 'json',
|
||||
...data,
|
||||
};
|
||||
|
||||
postData.api_sig = createApiSig(postData, config.secret);
|
||||
fetch('https://ws.audioscrobbler.com/2.0/', {method: 'POST', body: createFormData(postData)})
|
||||
.catch(res => {
|
||||
if (res.response.data.error == 9) {
|
||||
// session key is invalid, so remove it from the config and reauthenticate
|
||||
config.session_key = undefined;
|
||||
setOptions('last-fm', config);
|
||||
authenticate(config);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addScrobble = (songInfo, config) => {
|
||||
// this adds one scrobbled song to last.fm
|
||||
const data = {
|
||||
method: 'track.scrobble',
|
||||
timestamp: ~~((Date.now() - songInfo.elapsedSeconds) / 1000),
|
||||
};
|
||||
postSongDataToAPI(songInfo, config, data);
|
||||
}
|
||||
|
||||
const setNowPlaying = (songInfo, config) => {
|
||||
// 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 = undefined;
|
||||
|
||||
const lastfm = async (_win, config) => {
|
||||
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 half way through, or has passed the 4 minute mark
|
||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||
if (scrobbleTime > songInfo.elapsedSeconds) {
|
||||
// scrobble still needs to happen
|
||||
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000;
|
||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = lastfm;
|
||||
52
plugins/lyrics-genius/back.js
Normal file
52
plugins/lyrics-genius/back.js
Normal file
@ -0,0 +1,52 @@
|
||||
const { join } = require("path");
|
||||
|
||||
const { ipcMain } = require("electron");
|
||||
const is = require("electron-is");
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
const { cleanupName } = require("../../providers/song-info");
|
||||
const { injectCSS } = require("../utils");
|
||||
|
||||
module.exports = async (win) => {
|
||||
injectCSS(win.webContents, join(__dirname, "style.css"));
|
||||
|
||||
ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => {
|
||||
const metadata = JSON.parse(extractedSongInfo);
|
||||
const queryString = `${cleanupName(metadata.artist)} ${cleanupName(
|
||||
metadata.title
|
||||
)}`;
|
||||
|
||||
let response = await fetch(
|
||||
`https://genius.com/api/search/multi?per_page=5&q=${encodeURI(
|
||||
queryString
|
||||
)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
event.returnValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await response.json();
|
||||
let url = "";
|
||||
try {
|
||||
url = info.response.sections.filter(
|
||||
(section) => section.type === "song"
|
||||
)[0].hits[0].result.url;
|
||||
} catch {
|
||||
event.returnValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (is.dev()) {
|
||||
console.log("Fetching lyrics from Genius:", url);
|
||||
}
|
||||
|
||||
response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
event.returnValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
event.returnValue = await response.text();
|
||||
});
|
||||
};
|
||||
67
plugins/lyrics-genius/front.js
Normal file
67
plugins/lyrics-genius/front.js
Normal file
@ -0,0 +1,67 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const is = require("electron-is");
|
||||
|
||||
module.exports = () => {
|
||||
ipcRenderer.on("update-song-info", (_, extractedSongInfo) => {
|
||||
const lyricsTab = document.querySelector('tp-yt-paper-tab[tabindex="-1"]');
|
||||
|
||||
// Check if disabled
|
||||
if (!lyricsTab || !lyricsTab.hasAttribute("disabled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = ipcRenderer.sendSync(
|
||||
"search-genius-lyrics",
|
||||
extractedSongInfo
|
||||
);
|
||||
if (!html) {
|
||||
return;
|
||||
} else if (is.dev()) {
|
||||
console.log("Fetched lyrics from Genius");
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
const lyricsSelector1 = wrapper.querySelector(".lyrics");
|
||||
const lyricsSelector2 = wrapper.querySelector(
|
||||
'[class^="Lyrics__Container"]'
|
||||
);
|
||||
const lyrics = lyricsSelector1
|
||||
? lyricsSelector1.innerHTML
|
||||
: lyricsSelector2
|
||||
? lyricsSelector2.innerHTML
|
||||
: null;
|
||||
if (!lyrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
lyricsTab.removeAttribute("disabled");
|
||||
lyricsTab.removeAttribute("aria-disabled");
|
||||
document.querySelector("tp-yt-paper-tab").onclick = () => {
|
||||
lyricsTab.removeAttribute("disabled");
|
||||
lyricsTab.removeAttribute("aria-disabled");
|
||||
};
|
||||
|
||||
lyricsTab.onclick = () => {
|
||||
const tabContainer = document.querySelector("ytmusic-tab-renderer");
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
const lyricsContainer = document.querySelector(
|
||||
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer'
|
||||
);
|
||||
if (lyricsContainer) {
|
||||
lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer genius-lyrics">
|
||||
${lyrics}
|
||||
|
||||
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer">Source : Genius</yt-formatted-string>
|
||||
</div>`;
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
};
|
||||
12
plugins/lyrics-genius/style.css
Normal file
12
plugins/lyrics-genius/style.css
Normal file
@ -0,0 +1,12 @@
|
||||
/* Disable links in Genius lyrics */
|
||||
.genius-lyrics a {
|
||||
color: var(--ytmusic-text-primary);
|
||||
display: inline-block;
|
||||
pointer-events: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#contents.genius-lyrics {
|
||||
font-size: 1vw;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@ -4,7 +4,10 @@ const { injectCSS, listenAction } = require("../utils");
|
||||
const { ACTIONS, CHANNEL } = require("./actions.js");
|
||||
|
||||
function handle(win) {
|
||||
injectCSS(win.webContents, path.join(__dirname, "style.css"));
|
||||
injectCSS(win.webContents, path.join(__dirname, "style.css"), () => {
|
||||
win.webContents.send("navigation-css-ready");
|
||||
});
|
||||
|
||||
listenAction(CHANNEL, (event, action) => {
|
||||
switch (action) {
|
||||
case ACTIONS.NEXT:
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
const { ElementFromFile, templatePath } = require("../utils");
|
||||
|
||||
function run() {
|
||||
const forwardButton = ElementFromFile(
|
||||
templatePath(__dirname, "forward.html")
|
||||
);
|
||||
const backButton = ElementFromFile(templatePath(__dirname, "back.html"));
|
||||
const menu = document.querySelector("ytmusic-pivot-bar-renderer");
|
||||
ipcRenderer.on("navigation-css-ready", () => {
|
||||
const forwardButton = ElementFromFile(
|
||||
templatePath(__dirname, "forward.html")
|
||||
);
|
||||
const backButton = ElementFromFile(templatePath(__dirname, "back.html"));
|
||||
const menu = document.querySelector("ytmusic-pivot-bar-renderer");
|
||||
|
||||
if (menu) {
|
||||
menu.prepend(backButton, forwardButton);
|
||||
}
|
||||
if (menu) {
|
||||
menu.prepend(backButton, forwardButton);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = run;
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
focusable="false"
|
||||
class="style-scope iron-icon"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%;"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
||||
>
|
||||
<g class="style-scope iron-icon">
|
||||
<path
|
||||
|
||||
@ -1,41 +1,46 @@
|
||||
const { Notification } = require("electron");
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
const is = require("electron-is");
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
const { notificationImage } = require("./utils");
|
||||
|
||||
const notify = info => {
|
||||
let notificationImage = "assets/youtube-music.png";
|
||||
const setupInteractive = require("./interactive")
|
||||
|
||||
if (info.image) {
|
||||
notificationImage = info.image.resize({ height: 256, width: 256 });
|
||||
}
|
||||
const notify = (info, options) => {
|
||||
|
||||
// Fill the notification with content
|
||||
const notification = {
|
||||
title: info.title || "Playing",
|
||||
body: info.artist,
|
||||
icon: notificationImage,
|
||||
icon: notificationImage(info),
|
||||
silent: true,
|
||||
urgency: options.urgency,
|
||||
};
|
||||
|
||||
|
||||
// Send the notification
|
||||
currentNotification = new Notification(notification);
|
||||
const currentNotification = new Notification(notification);
|
||||
currentNotification.show()
|
||||
|
||||
|
||||
return currentNotification;
|
||||
};
|
||||
|
||||
module.exports = (win) => {
|
||||
const registerCallback = getSongInfo(win);
|
||||
const setup = (options) => {
|
||||
let oldNotification;
|
||||
win.on("ready-to-show", () => {
|
||||
// Register the callback for new song information
|
||||
registerCallback(songInfo => {
|
||||
// If song is playing send notification
|
||||
if (!songInfo.isPaused) {
|
||||
// Close the old notification
|
||||
oldNotification?.close();
|
||||
// This fixes a weird bug that would cause the notification to be updated instead of showing
|
||||
setTimeout(()=>{ oldNotification = notify(songInfo) }, 10);
|
||||
}
|
||||
});
|
||||
let currentUrl;
|
||||
|
||||
registerCallback(songInfo => {
|
||||
if (!songInfo.isPaused && (songInfo.url !== currentUrl || options.unpauseNotification)) {
|
||||
// Close the old notification
|
||||
oldNotification?.close();
|
||||
currentUrl = songInfo.url;
|
||||
// This fixes a weird bug that would cause the notification to be updated instead of showing
|
||||
setTimeout(() => { oldNotification = notify(songInfo, options) }, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = (win, options) => {
|
||||
// Register the callback for new song information
|
||||
is.windows() && options.interactive ?
|
||||
setupInteractive(win, options.unpauseNotification) :
|
||||
setup(options);
|
||||
};
|
||||
|
||||
103
plugins/notifications/interactive.js
Normal file
103
plugins/notifications/interactive.js
Normal file
@ -0,0 +1,103 @@
|
||||
const { notificationImage, icons } = require("./utils");
|
||||
const getSongControls = require('../../providers/song-controls');
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
const notifier = require("node-notifier");
|
||||
|
||||
//store song controls reference on launch
|
||||
let controls;
|
||||
let notificationOnUnpause;
|
||||
|
||||
module.exports = (win, unpauseNotification) => {
|
||||
//Save controls and onPause option
|
||||
const { playPause, next, previous } = getSongControls(win);
|
||||
controls = { playPause, next, previous };
|
||||
notificationOnUnpause = unpauseNotification;
|
||||
|
||||
let currentUrl;
|
||||
|
||||
// Register songInfoCallback
|
||||
registerCallback(songInfo => {
|
||||
if (!songInfo.isPaused && (songInfo.url !== currentUrl || notificationOnUnpause)) {
|
||||
currentUrl = songInfo.url;
|
||||
sendToaster(songInfo);
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.once("closed", () => {
|
||||
deleteNotification()
|
||||
});
|
||||
}
|
||||
|
||||
//delete old notification
|
||||
let toDelete;
|
||||
function deleteNotification() {
|
||||
if (toDelete !== undefined) {
|
||||
// To remove the notification it has to be done this way
|
||||
const removeNotif = Object.assign(toDelete, {
|
||||
remove: toDelete.id
|
||||
})
|
||||
notifier.notify(removeNotif)
|
||||
|
||||
toDelete = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
//New notification
|
||||
function sendToaster(songInfo) {
|
||||
deleteNotification();
|
||||
//download image and get path
|
||||
let imgSrc = notificationImage(songInfo, true);
|
||||
toDelete = {
|
||||
//app id undefined - will break buttons
|
||||
title: songInfo.title || "Playing",
|
||||
message: songInfo.artist,
|
||||
id: parseInt(Math.random() * 1000000, 10),
|
||||
icon: imgSrc,
|
||||
actions: [
|
||||
icons.previous,
|
||||
songInfo.isPaused ? icons.play : icons.pause,
|
||||
icons.next
|
||||
],
|
||||
sound: false,
|
||||
};
|
||||
//send notification
|
||||
notifier.notify(
|
||||
toDelete,
|
||||
(err, data) => {
|
||||
// Will also wait until notification is closed.
|
||||
if (err) {
|
||||
console.log(`ERROR = ${err.toString()}\n DATA = ${data}`);
|
||||
}
|
||||
switch (data) {
|
||||
//buttons
|
||||
case icons.previous.normalize():
|
||||
controls.previous();
|
||||
return;
|
||||
case icons.next.normalize():
|
||||
controls.next();
|
||||
return;
|
||||
case icons.play.normalize():
|
||||
controls.playPause();
|
||||
// dont delete notification on play/pause
|
||||
toDelete = undefined;
|
||||
//manually send notification if not sending automatically
|
||||
if (!notificationOnUnpause) {
|
||||
songInfo.isPaused = false;
|
||||
sendToaster(songInfo);
|
||||
}
|
||||
return;
|
||||
case icons.pause.normalize():
|
||||
controls.playPause();
|
||||
songInfo.isPaused = true;
|
||||
toDelete = undefined;
|
||||
sendToaster(songInfo);
|
||||
return;
|
||||
//Native datatype
|
||||
case "dismissed":
|
||||
case "timeout":
|
||||
deleteNotification();
|
||||
}
|
||||
}
|
||||
|
||||
);
|
||||
}
|
||||
30
plugins/notifications/menu.js
Normal file
30
plugins/notifications/menu.js
Normal file
@ -0,0 +1,30 @@
|
||||
const { urgencyLevels, setOption } = require("./utils");
|
||||
const is = require("electron-is");
|
||||
|
||||
module.exports = (win, options) => [
|
||||
...(is.linux() ?
|
||||
[{
|
||||
label: "Notification Priority",
|
||||
submenu: urgencyLevels.map(level => ({
|
||||
label: level.name,
|
||||
type: "radio",
|
||||
checked: options.urgency === level.value,
|
||||
click: () => setOption(options, "urgency", level.value)
|
||||
})),
|
||||
}] :
|
||||
[]),
|
||||
...(is.windows() ?
|
||||
[{
|
||||
label: "Interactive Notifications",
|
||||
type: "checkbox",
|
||||
checked: options.interactive,
|
||||
click: (item) => setOption(options, "interactive", item.checked)
|
||||
}] :
|
||||
[]),
|
||||
{
|
||||
label: "Show notification on unpause",
|
||||
type: "checkbox",
|
||||
checked: options.unpauseNotification,
|
||||
click: (item) => setOption(options, "unpauseNotification", item.checked)
|
||||
},
|
||||
];
|
||||
56
plugins/notifications/utils.js
Normal file
56
plugins/notifications/utils.js
Normal file
@ -0,0 +1,56 @@
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const path = require("path");
|
||||
const { app } = require("electron");
|
||||
const fs = require("fs");
|
||||
|
||||
const icon = "assets/youtube-music.png";
|
||||
const tempIcon = path.join(app.getPath("userData"), "tempIcon.png");
|
||||
|
||||
module.exports.icons = {
|
||||
play: "\u{1405}", // ᐅ
|
||||
pause: "\u{2016}", // ‖
|
||||
next: "\u{1433}", // ᐳ
|
||||
previous: "\u{1438}" // ᐸ
|
||||
}
|
||||
|
||||
module.exports.setOption = (options, option, value) => {
|
||||
options[option] = value;
|
||||
setOptions("notifications", options)
|
||||
}
|
||||
|
||||
module.exports.urgencyLevels = [
|
||||
{ name: "Low", value: "low" },
|
||||
{ name: "Normal", value: "normal" },
|
||||
{ name: "High", value: "critical" },
|
||||
];
|
||||
|
||||
module.exports.notificationImage = function (songInfo, saveIcon = false) {
|
||||
//return local path to temp icon
|
||||
if (saveIcon && !!songInfo.image) {
|
||||
try {
|
||||
fs.writeFileSync(tempIcon,
|
||||
centerNativeImage(songInfo.image)
|
||||
.toPNG()
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(`Error writing song icon to disk:\n${err.toString()}`)
|
||||
return icon;
|
||||
}
|
||||
return tempIcon;
|
||||
}
|
||||
//else: return image
|
||||
return songInfo.image
|
||||
? centerNativeImage(songInfo.image)
|
||||
: icon
|
||||
};
|
||||
|
||||
function centerNativeImage(nativeImage) {
|
||||
const tempImage = nativeImage.resize({ height: 256 });
|
||||
const margin = Math.max((tempImage.getSize().width - 256), 0);
|
||||
|
||||
return tempImage.crop({
|
||||
x: Math.round(margin / 2),
|
||||
y: 0,
|
||||
width: 256, height: 256
|
||||
})
|
||||
}
|
||||
@ -1,81 +1,93 @@
|
||||
const { watchDOMElement } = require("../../providers/dom-elements");
|
||||
const { getSongMenu } = require("../../providers/dom-elements");
|
||||
const { ElementFromFile, templatePath } = require("../utils");
|
||||
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
|
||||
const slider = ElementFromFile(templatePath(__dirname, "slider.html"));
|
||||
|
||||
const MIN_PLAYBACK_SPEED = 0.25;
|
||||
const MAX_PLAYBACK_SPEED = 2;
|
||||
const roundToTwo = n => Math.round(n * 1e2) / 1e2;
|
||||
|
||||
let videoElement;
|
||||
let playbackSpeedPercentage = 50; // = Playback speed of 1
|
||||
const MIN_PLAYBACK_SPEED = 0.07;
|
||||
const MAX_PLAYBACK_SPEED = 16;
|
||||
|
||||
const computePlayBackSpeed = () => {
|
||||
if (playbackSpeedPercentage <= 50) {
|
||||
// Slow down video by setting a playback speed between MIN_PLAYBACK_SPEED and 1
|
||||
return (
|
||||
MIN_PLAYBACK_SPEED +
|
||||
((1 - MIN_PLAYBACK_SPEED) / 50) * playbackSpeedPercentage
|
||||
);
|
||||
}
|
||||
|
||||
// Accelerate video by setting a playback speed between 1 and MAX_PLAYBACK_SPEED
|
||||
return 1 + ((MAX_PLAYBACK_SPEED - 1) / 50) * (playbackSpeedPercentage - 50);
|
||||
};
|
||||
let playbackSpeed = 1;
|
||||
|
||||
const updatePlayBackSpeed = () => {
|
||||
const playbackSpeed = Math.round(computePlayBackSpeed() * 100) / 100;
|
||||
$('video').playbackRate = playbackSpeed;
|
||||
|
||||
if (!videoElement || videoElement.playbackRate === playbackSpeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoElement.playbackRate = playbackSpeed;
|
||||
|
||||
const playbackSpeedElement = document.querySelector("#playback-speed-value");
|
||||
const playbackSpeedElement = $("#playback-speed-value");
|
||||
if (playbackSpeedElement) {
|
||||
playbackSpeedElement.innerHTML = playbackSpeed;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = () => {
|
||||
watchDOMElement(
|
||||
"video",
|
||||
(document) => document.querySelector("video"),
|
||||
(element) => {
|
||||
videoElement = element;
|
||||
updatePlayBackSpeed();
|
||||
}
|
||||
);
|
||||
let menu;
|
||||
let observingSlider = false;
|
||||
|
||||
watchDOMElement(
|
||||
"menu",
|
||||
(document) =>
|
||||
document.querySelector("ytmusic-menu-popup-renderer paper-listbox"),
|
||||
(menuElement) => {
|
||||
if (!menuElement.contains(slider)) {
|
||||
menuElement.prepend(slider);
|
||||
const observePopupContainer = () => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
}
|
||||
|
||||
if (menu && !menu.contains(slider)) {
|
||||
menu.prepend(slider);
|
||||
if (!observingSlider) {
|
||||
setupSliderListener();
|
||||
observingSlider = true;
|
||||
}
|
||||
|
||||
const playbackSpeedElement = document.querySelector(
|
||||
"#playback-speed-slider #sliderKnob .slider-knob-inner"
|
||||
);
|
||||
|
||||
const playbackSpeedObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.type == "attributes") {
|
||||
const value = playbackSpeedElement.getAttribute("value");
|
||||
playbackSpeedPercentage = parseInt(value, 10);
|
||||
if (isNaN(playbackSpeedPercentage)) {
|
||||
playbackSpeedPercentage = 50;
|
||||
}
|
||||
updatePlayBackSpeed();
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
playbackSpeedObserver.observe(playbackSpeedElement, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe($('ytmusic-popup-container'), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
const observeVideo = () => {
|
||||
$('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
|
||||
$('#playback-speed-slider').value = playbackSpeed;
|
||||
})
|
||||
}
|
||||
|
||||
function setupSliderListener() {
|
||||
$('#playback-speed-slider').addEventListener('immediate-value-changed', e => {
|
||||
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
|
||||
if (isNaN(playbackSpeed)) {
|
||||
playbackSpeed = 1;
|
||||
}
|
||||
updatePlayBackSpeed();
|
||||
})
|
||||
}
|
||||
|
||||
function forcePlaybackRate(e) {
|
||||
if (e.target.playbackRate !== playbackSpeed) {
|
||||
e.target.playbackRate = playbackSpeed
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
document.addEventListener('apiLoaded', e => {
|
||||
observePopupContainer();
|
||||
observeVideo();
|
||||
setupWheelListener();
|
||||
}, { once: true, passive: true })
|
||||
};
|
||||
|
||||
@ -1,94 +1,88 @@
|
||||
<div
|
||||
class="menu-item ytmusic-menu-popup-renderer"
|
||||
class="style-scope menu-item ytmusic-menu-popup-renderer"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-disabled="false"
|
||||
aria-selected="false"
|
||||
>
|
||||
<paper-slider
|
||||
id="playback-speed-slider"
|
||||
class="volume-slider style-scope ytmusic-player-bar on-hover"
|
||||
max="100"
|
||||
min="0"
|
||||
step="5"
|
||||
dir="ltr"
|
||||
title="Playback speed"
|
||||
aria-label="Playback speed"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="50"
|
||||
aria-disabled="false"
|
||||
value="50"
|
||||
><!--css-build:shady-->
|
||||
<div id="sliderContainer" class="style-scope paper-slider">
|
||||
<div class="bar-container style-scope paper-slider">
|
||||
<paper-progress
|
||||
id="sliderBar"
|
||||
aria-hidden="true"
|
||||
class="style-scope paper-slider"
|
||||
role="progressbar"
|
||||
value="50"
|
||||
aria-valuenow="50"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-disabled="false"
|
||||
style="touch-action: none;"
|
||||
><!--css-build:shady-->
|
||||
|
||||
<div id="progressContainer" class="style-scope paper-progress">
|
||||
<div
|
||||
id="secondaryProgress"
|
||||
class="style-scope paper-progress"
|
||||
hidden="true"
|
||||
style="transform: scaleX(0);"
|
||||
></div>
|
||||
<div
|
||||
id="primaryProgress"
|
||||
class="style-scope paper-progress"
|
||||
style="transform: scaleX(0.5);"
|
||||
></div>
|
||||
</div>
|
||||
</paper-progress>
|
||||
</div>
|
||||
<dom-if class="style-scope paper-slider"
|
||||
><template is="dom-if"></template
|
||||
></dom-if>
|
||||
<div
|
||||
id="sliderKnob"
|
||||
class="slider-knob style-scope paper-slider"
|
||||
style="left: 50%; touch-action: none;"
|
||||
>
|
||||
<div
|
||||
class="slider-knob-inner style-scope paper-slider"
|
||||
value="50"
|
||||
></div>
|
||||
<paper-ripple
|
||||
id="ink"
|
||||
center=""
|
||||
class="circle style-scope paper-slider"
|
||||
style="display: none;"
|
||||
><!--css-build:shady-->
|
||||
|
||||
<div
|
||||
id="background"
|
||||
class="style-scope paper-ripple"
|
||||
style="opacity: 0.006008;"
|
||||
></div>
|
||||
<div id="waves" class="style-scope paper-ripple"></div>
|
||||
</paper-ripple>
|
||||
</div>
|
||||
</div>
|
||||
<dom-if class="style-scope paper-slider"
|
||||
><template is="dom-if"></template
|
||||
></dom-if>
|
||||
</paper-slider>
|
||||
|
||||
<div
|
||||
class="text style-scope ytmusic-toggle-menu-service-item-renderer"
|
||||
id="ytmcustom-playback-speed"
|
||||
id="navigation-endpoint"
|
||||
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
|
||||
tabindex="-1"
|
||||
>
|
||||
Speed (<span id="playback-speed-value">1</span>)
|
||||
<tp-yt-paper-slider
|
||||
id="playback-speed-slider"
|
||||
class="volume-slider style-scope ytmusic-player-bar on-hover"
|
||||
style="display: inherit !important"
|
||||
max="2"
|
||||
min="0"
|
||||
step="0.125"
|
||||
dir="ltr"
|
||||
title="Playback speed"
|
||||
aria-label="Playback speed"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="2"
|
||||
aria-valuenow="1"
|
||||
aria-disabled="false"
|
||||
value="1"
|
||||
><!--css-build:shady-->
|
||||
<div id="sliderContainer" class="style-scope tp-yt-paper-slider">
|
||||
<div class="bar-container style-scope tp-yt-paper-slider">
|
||||
<tp-yt-paper-progress
|
||||
id="sliderBar"
|
||||
aria-hidden="true"
|
||||
class="style-scope tp-yt-paper-slider"
|
||||
role="progressbar"
|
||||
value="1"
|
||||
aria-valuenow="1"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="2"
|
||||
aria-disabled="false"
|
||||
style="touch-action: none"
|
||||
><!--css-build:shady-->
|
||||
|
||||
<div
|
||||
id="progressContainer"
|
||||
class="style-scope tp-yt-paper-progress"
|
||||
>
|
||||
<div
|
||||
id="secondaryProgress"
|
||||
class="style-scope tp-yt-paper-progress"
|
||||
hidden="true"
|
||||
style="transform: scaleX(0)"
|
||||
></div>
|
||||
<div
|
||||
id="primaryProgress"
|
||||
class="style-scope tp-yt-paper-progress"
|
||||
style="transform: scaleX(0.5)"
|
||||
></div>
|
||||
</div>
|
||||
</tp-yt-paper-progress>
|
||||
</div>
|
||||
<dom-if class="style-scope tp-yt-paper-slider"
|
||||
><template is="dom-if"></template
|
||||
></dom-if>
|
||||
<div
|
||||
id="sliderKnob"
|
||||
class="slider-knob style-scope tp-yt-paper-slider"
|
||||
style="left: 50%; touch-action: none"
|
||||
>
|
||||
<div
|
||||
class="slider-knob-inner style-scope tp-yt-paper-slider"
|
||||
value="1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<dom-if class="style-scope tp-yt-paper-slider"
|
||||
><template is="dom-if"></template></dom-if
|
||||
></tp-yt-paper-slider>
|
||||
<div
|
||||
class="text style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="ytmcustom-playback-speed"
|
||||
>
|
||||
Speed (<span id="playback-speed-value">1</span>)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
9
plugins/precise-volume/back.js
Normal file
9
plugins/precise-volume/back.js
Normal file
@ -0,0 +1,9 @@
|
||||
/*
|
||||
This is used to determine if plugin is actually active
|
||||
(not if its only enabled in options)
|
||||
*/
|
||||
let enabled = false;
|
||||
|
||||
module.exports = () => enabled = true;
|
||||
|
||||
module.exports.enabled = () => enabled;
|
||||
236
plugins/precise-volume/front.js
Normal file
236
plugins/precise-volume/front.js
Normal file
@ -0,0 +1,236 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const { globalShortcut } = require('@electron/remote');
|
||||
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
let api;
|
||||
|
||||
module.exports = (options) => {
|
||||
document.addEventListener('apiLoaded', e => {
|
||||
api = e.detail;
|
||||
firstRun(options);
|
||||
}, { once: true, passive: true })
|
||||
};
|
||||
|
||||
/** Restore saved volume and setup tooltip */
|
||||
function firstRun(options) {
|
||||
if (typeof options.savedVolume === "number") {
|
||||
// Set saved volume as tooltip
|
||||
setTooltip(options.savedVolume);
|
||||
|
||||
if (api.getVolume() !== options.savedVolume) {
|
||||
api.setVolume(options.savedVolume);
|
||||
}
|
||||
}
|
||||
|
||||
setupPlaybar(options);
|
||||
|
||||
setupLocalArrowShortcuts(options);
|
||||
|
||||
setupGlobalShortcuts(options);
|
||||
|
||||
const noVid = $("#main-panel")?.computedStyleMap().get("display").value === "none";
|
||||
injectVolumeHud(noVid);
|
||||
if (!noVid) {
|
||||
setupVideoPlayerOnwheel(options);
|
||||
}
|
||||
|
||||
// Change options from renderer to keep sync
|
||||
ipcRenderer.on("setOptions", (_event, newOptions = {}) => {
|
||||
for (option in newOptions) {
|
||||
options[option] = newOptions[option];
|
||||
}
|
||||
setOptions("precise-volume", options);
|
||||
});
|
||||
}
|
||||
|
||||
function injectVolumeHud(noVid) {
|
||||
if (noVid) {
|
||||
const position = "top: 18px; right: 60px; z-index: 999; position: absolute;";
|
||||
const mainStyle = "font-size: xx-large; padding: 10px; transition: opacity 1s; pointer-events: none;";
|
||||
|
||||
$(".center-content.ytmusic-nav-bar").insertAdjacentHTML("beforeend",
|
||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`)
|
||||
} else {
|
||||
const position = `top: 10px; left: 10px; z-index: 999; position: absolute;`;
|
||||
const mainStyle = "font-size: xxx-large; padding: 10px; transition: opacity 0.6s; webkit-text-stroke: 1px black; font-weight: 600; pointer-events: none;";
|
||||
|
||||
$("#song-video").insertAdjacentHTML('afterend',
|
||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`)
|
||||
}
|
||||
}
|
||||
|
||||
let hudFadeTimeout;
|
||||
|
||||
function showVolumeHud(volume) {
|
||||
let volumeHud = $("#volumeHud");
|
||||
if (!volumeHud) return;
|
||||
|
||||
volumeHud.textContent = volume + '%';
|
||||
volumeHud.style.opacity = 1;
|
||||
|
||||
if (hudFadeTimeout) {
|
||||
clearTimeout(hudFadeTimeout);
|
||||
}
|
||||
|
||||
hudFadeTimeout = setTimeout(() => {
|
||||
volumeHud.style.opacity = 0;
|
||||
hudFadeTimeout = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Add onwheel event to video player */
|
||||
function setupVideoPlayerOnwheel(options) {
|
||||
$("#main-panel").addEventListener("wheel", event => {
|
||||
event.preventDefault();
|
||||
// Event.deltaY < 0 means wheel-up
|
||||
changeVolume(event.deltaY < 0, options);
|
||||
});
|
||||
}
|
||||
|
||||
function saveVolume(volume, options) {
|
||||
options.savedVolume = volume;
|
||||
writeOptions(options);
|
||||
}
|
||||
|
||||
//without this function it would rewrite config 20 time when volume change by 20
|
||||
let writeTimeout;
|
||||
function writeOptions(options) {
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
|
||||
writeTimeout = setTimeout(() => {
|
||||
setOptions("precise-volume", options);
|
||||
writeTimeout = null;
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/** Add onwheel event to play bar and also track if play bar is hovered*/
|
||||
function setupPlaybar(options) {
|
||||
const playerbar = $("ytmusic-player-bar");
|
||||
|
||||
playerbar.addEventListener("wheel", event => {
|
||||
event.preventDefault();
|
||||
// Event.deltaY < 0 means wheel-up
|
||||
changeVolume(event.deltaY < 0, options);
|
||||
});
|
||||
|
||||
// Keep track of mouse position for showVolumeSlider()
|
||||
playerbar.addEventListener("mouseenter", () => {
|
||||
playerbar.classList.add("on-hover");
|
||||
});
|
||||
|
||||
playerbar.addEventListener("mouseleave", () => {
|
||||
playerbar.classList.remove("on-hover");
|
||||
});
|
||||
|
||||
setupSliderObserver(options);
|
||||
}
|
||||
|
||||
/** Save volume + Update the volume tooltip when volume-slider is manually changed */
|
||||
function setupSliderObserver(options) {
|
||||
const sliderObserver = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
// This checks that volume-slider was manually set
|
||||
if (mutation.oldValue !== mutation.target.value &&
|
||||
(typeof options.savedVolume !== "number" || Math.abs(options.savedVolume - mutation.target.value) > 4)) {
|
||||
// Diff>4 means it was manually set
|
||||
setTooltip(mutation.target.value);
|
||||
saveVolume(mutation.target.value, options);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observing only changes in 'value' of volume-slider
|
||||
sliderObserver.observe($("#volume-slider"), {
|
||||
attributeFilter: ["value"],
|
||||
attributeOldValue: true
|
||||
});
|
||||
}
|
||||
|
||||
/** if (toIncrease = false) then volume decrease */
|
||||
function changeVolume(toIncrease, options) {
|
||||
// Apply volume change if valid
|
||||
const steps = Number(options.steps || 1);
|
||||
api.setVolume(toIncrease ?
|
||||
Math.min(api.getVolume() + steps, 100) :
|
||||
Math.max(api.getVolume() - steps, 0));
|
||||
|
||||
// Save the new volume
|
||||
saveVolume(api.getVolume(), options);
|
||||
|
||||
// change slider position (important)
|
||||
updateVolumeSlider(options);
|
||||
|
||||
// Change tooltips to new value
|
||||
setTooltip(options.savedVolume);
|
||||
// Show volume slider
|
||||
showVolumeSlider();
|
||||
// Show volume HUD
|
||||
showVolumeHud(options.savedVolume);
|
||||
}
|
||||
|
||||
function updateVolumeSlider(options) {
|
||||
// Slider value automatically rounds to multiples of 5
|
||||
$("#volume-slider").value = options.savedVolume > 0 && options.savedVolume < 5 ?
|
||||
5 : options.savedVolume;
|
||||
}
|
||||
|
||||
let volumeHoverTimeoutID;
|
||||
|
||||
function showVolumeSlider() {
|
||||
const slider = $("#volume-slider");
|
||||
// This class display the volume slider if not in minimized mode
|
||||
slider.classList.add("on-hover");
|
||||
// Reset timeout if previous one hasn't completed
|
||||
if (volumeHoverTimeoutID) {
|
||||
clearTimeout(volumeHoverTimeoutID);
|
||||
}
|
||||
// Timeout to remove volume preview after 3 seconds if playbar isn't hovered
|
||||
volumeHoverTimeoutID = setTimeout(() => {
|
||||
volumeHoverTimeoutID = null;
|
||||
if (!$("ytmusic-player-bar").classList.contains("on-hover")) {
|
||||
slider.classList.remove("on-hover");
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (target of tooltipTargets) {
|
||||
$(target).title = `${volume}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupGlobalShortcuts(options) {
|
||||
if (options.globalShortcuts.volumeUp) {
|
||||
globalShortcut.register((options.globalShortcuts.volumeUp), () => changeVolume(true, options));
|
||||
}
|
||||
if (options.globalShortcuts.volumeDown) {
|
||||
globalShortcut.register((options.globalShortcuts.volumeDown), () => changeVolume(false, options));
|
||||
}
|
||||
}
|
||||
|
||||
function setupLocalArrowShortcuts(options) {
|
||||
if (options.arrowsShortcut) {
|
||||
window.addEventListener('keydown', (event) => {
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
changeVolume(true, options);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
changeVolume(false, options);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
82
plugins/precise-volume/menu.js
Normal file
82
plugins/precise-volume/menu.js
Normal file
@ -0,0 +1,82 @@
|
||||
const { enabled } = require("./back");
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const prompt = require("custom-electron-prompt");
|
||||
const promptOptions = require("../../providers/prompt-options");
|
||||
|
||||
function changeOptions(changedOptions, options, win) {
|
||||
for (option in changedOptions) {
|
||||
options[option] = changedOptions[option];
|
||||
}
|
||||
// Dynamically change setting if plugin is enabled
|
||||
if (enabled()) {
|
||||
win.webContents.send("setOptions", changedOptions);
|
||||
} else { // Fallback to usual method if disabled
|
||||
setOptions("precise-volume", options);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (win, options) => [
|
||||
{
|
||||
label: "Local Arrowkeys Controls",
|
||||
type: "checkbox",
|
||||
checked: !!options.arrowsShortcut,
|
||||
click: item => {
|
||||
changeOptions({ arrowsShortcut: item.checked }, options, win);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Global Hotkeys",
|
||||
type: "checkbox",
|
||||
checked: !!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_, value_, default_) => { return { value: value_, label: label_, default: default_ || undefined }; };
|
||||
|
||||
async function promptVolumeSteps(win, options) {
|
||||
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, options, item) {
|
||||
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) {
|
||||
let newGlobalShortcuts = {};
|
||||
for (const { value, accelerator } of output) {
|
||||
newGlobalShortcuts[value] = accelerator;
|
||||
}
|
||||
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
|
||||
|
||||
item.checked = !!options.globalShortcuts.volumeUp || !!options.globalShortcuts.volumeDown;
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
}
|
||||
33
plugins/precise-volume/preload.js
Normal file
33
plugins/precise-volume/preload.js
Normal file
@ -0,0 +1,33 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const is = require("electron-is");
|
||||
|
||||
let ignored = {
|
||||
id: ["volume-slider", "expand-volume-slider"],
|
||||
types: ["mousewheel", "keydown", "keyup"]
|
||||
};
|
||||
|
||||
function overrideAddEventListener() {
|
||||
// Save native addEventListener
|
||||
Element.prototype._addEventListener = Element.prototype.addEventListener;
|
||||
// Override addEventListener to Ignore specific events in volume-slider
|
||||
Element.prototype.addEventListener = function (type, listener, useCapture = false) {
|
||||
if (!(
|
||||
ignored.id.includes(this.id) &&
|
||||
ignored.types.includes(type)
|
||||
)) {
|
||||
this._addEventListener(type, listener, useCapture);
|
||||
} else if (is.dev()) {
|
||||
console.log(`Ignoring event: "${this.id}.${type}()"`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
overrideAddEventListener();
|
||||
// Restore original function after finished loading to avoid keeping Element.prototype altered
|
||||
window.addEventListener('load', () => {
|
||||
Element.prototype.addEventListener = Element.prototype._addEventListener;
|
||||
Element.prototype._addEventListener = undefined;
|
||||
ignored = undefined;
|
||||
}, { once: true });
|
||||
};
|
||||
41
plugins/quality-changer/front.js
Normal file
41
plugins/quality-changer/front.js
Normal file
@ -0,0 +1,41 @@
|
||||
const { ElementFromFile, templatePath } = require("../utils");
|
||||
const { dialog } = require('@electron/remote');
|
||||
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
|
||||
const qualitySettingsButton = ElementFromFile(
|
||||
templatePath(__dirname, "qualitySettingsTemplate.html")
|
||||
);
|
||||
|
||||
|
||||
module.exports = () => {
|
||||
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
|
||||
}
|
||||
|
||||
function setup(event) {
|
||||
const api = event.detail;
|
||||
|
||||
$('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton);
|
||||
|
||||
qualitySettingsButton.onclick = function chooseQuality() {
|
||||
if (api.getPlayerState() === 2) api.playVideo();
|
||||
else if (api.getPlayerState() === 1) api.pauseVideo();
|
||||
|
||||
const currentIndex = api.getAvailableQualityLevels().indexOf(api.getPlaybackQuality())
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: "question",
|
||||
buttons: api.getAvailableQualityLabels(),
|
||||
defaultId: currentIndex,
|
||||
title: "Choose Video Quality",
|
||||
message: "Choose Video Quality:",
|
||||
detail: `Current Quality: ${api.getAvailableQualityLabels()[currentIndex]}`,
|
||||
cancelId: -1
|
||||
}).then((promise) => {
|
||||
if (promise.response === -1) return;
|
||||
const newQuality = api.getAvailableQualityLevels()[promise.response];
|
||||
api.setPlaybackQualityRange(newQuality);
|
||||
api.setPlaybackQuality(newQuality)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<tp-yt-paper-icon-button class="player-quality-button style-scope ytmusic-player" icon="yt-icons:settings"
|
||||
title="Open player quality changer" aria-label="Open player quality changer" role="button" tabindex="0" aria-disabled="false">
|
||||
<tp-yt-iron-icon id="icon" class="style-scope tp-yt-paper-icon-button"><svg viewBox="0 0 24 24"
|
||||
preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%;">
|
||||
<g class="style-scope yt-icon">
|
||||
<path
|
||||
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.1-1.65c.2-.15.25-.42.13-.64l-2-3.46c-.12-.22-.4-.3-.6-.22l-2.5 1c-.52-.4-1.08-.73-1.7-.98l-.37-2.65c-.06-.24-.27-.42-.5-.42h-4c-.27 0-.48.18-.5.42l-.4 2.65c-.6.25-1.17.6-1.7.98l-2.48-1c-.23-.1-.5 0-.6.22l-2 3.46c-.14.22-.08.5.1.64l2.12 1.65c-.04.32-.07.65-.07.98s.02.66.06.98l-2.1 1.65c-.2.15-.25.42-.13.64l2 3.46c.12.22.4.3.6.22l2.5-1c.52.4 1.08.73 1.7.98l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.6-.25 1.17-.6 1.7-.98l2.48 1c.23.1.5 0 .6-.22l2-3.46c.13-.22.08-.5-.1-.64l-2.12-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"
|
||||
class="style-scope yt-icon"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</tp-yt-iron-icon>
|
||||
</tp-yt-paper-icon-button>
|
||||
@ -1,7 +1,8 @@
|
||||
const { globalShortcut } = require("electron");
|
||||
const is = require("electron-is");
|
||||
const electronLocalshortcut = require("electron-localshortcut");
|
||||
|
||||
const getSongControls = require("../../providers/song-controls");
|
||||
const registerMPRIS = require("./mpris");
|
||||
|
||||
function _registerGlobalShortcut(webContents, shortcut, action) {
|
||||
globalShortcut.register(shortcut, () => {
|
||||
@ -19,31 +20,43 @@ function registerShortcuts(win, options) {
|
||||
const songControls = getSongControls(win);
|
||||
const { playPause, next, previous, search } = songControls;
|
||||
|
||||
_registerGlobalShortcut(win.webContents, "MediaPlayPause", playPause);
|
||||
_registerGlobalShortcut(win.webContents, "MediaNextTrack", next);
|
||||
_registerGlobalShortcut(win.webContents, "MediaPreviousTrack", previous);
|
||||
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;
|
||||
(global || []).forEach(({ shortcut, action }) => {
|
||||
console.debug("Registering global shortcut", shortcut, ":", action);
|
||||
if (!action || !songControls[action]) {
|
||||
console.warn("Invalid action", action);
|
||||
return;
|
||||
}
|
||||
const shortcutOptions = { global, local };
|
||||
|
||||
_registerGlobalShortcut(win.webContents, shortcut, songControls[action]);
|
||||
});
|
||||
(local || []).forEach(({ shortcut, action }) => {
|
||||
console.debug("Registering local shortcut", shortcut, ":", action);
|
||||
if (!action || !songControls[action]) {
|
||||
console.warn("Invalid action", action);
|
||||
return;
|
||||
}
|
||||
for (const optionType in shortcutOptions) {
|
||||
registerAllShortcuts(shortcutOptions[optionType], optionType);
|
||||
}
|
||||
|
||||
_registerLocalShortcut(win, shortcut, songControls[action]);
|
||||
});
|
||||
function registerAllShortcuts(container, type) {
|
||||
for (const action in container) {
|
||||
if (!container[action]) {
|
||||
continue; // Action accelerator is empty
|
||||
}
|
||||
|
||||
console.debug(`Registering ${type} shortcut`, container[action], ":", action);
|
||||
if (!songControls[action]) {
|
||||
console.warn("Invalid action", action);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "global") {
|
||||
_registerGlobalShortcut(win.webContents, container[action], songControls[action]);
|
||||
} else { // type === "local"
|
||||
_registerLocalShortcut(win, local[action], songControls[action]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = registerShortcuts;
|
||||
|
||||
53
plugins/shortcuts/menu.js
Normal file
53
plugins/shortcuts/menu.js
Normal file
@ -0,0 +1,53 @@
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const prompt = require("custom-electron-prompt");
|
||||
const promptOptions = require("../../providers/prompt-options");
|
||||
|
||||
module.exports = (win, options) => [
|
||||
{
|
||||
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(options, key = null, newValue = null) {
|
||||
if (key && newValue !== null) {
|
||||
options[key] = newValue;
|
||||
}
|
||||
|
||||
setOptions("shortcuts", options);
|
||||
}
|
||||
|
||||
// Helper function for keybind prompt
|
||||
const kb = (label_, value_, default_) => { return { value: value_, label: label_, default: default_ }; };
|
||||
|
||||
async function promptKeybind(options, win) {
|
||||
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
|
||||
}
|
||||
85
plugins/shortcuts/mpris.js
Normal file
85
plugins/shortcuts/mpris.js
Normal file
@ -0,0 +1,85 @@
|
||||
const mpris = require("mpris-service");
|
||||
const { ipcMain } = require("electron");
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
const getSongControls = require("../../providers/song-controls");
|
||||
|
||||
function setupMPRIS() {
|
||||
const player = mpris({
|
||||
name: "youtube-music",
|
||||
identity: "YouTube Music",
|
||||
canRaise: true,
|
||||
supportedUriSchemes: ["https"],
|
||||
supportedMimeTypes: ["audio/mpeg"],
|
||||
supportedInterfaces: ["player"],
|
||||
desktopEntry: "youtube-music",
|
||||
});
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
function registerMPRIS(win) {
|
||||
const songControls = getSongControls(win);
|
||||
const { playPause, next, previous } = songControls;
|
||||
try {
|
||||
const secToMicro = n => Math.round(Number(n) * 1e6);
|
||||
const microToSec = n => Math.round(Number(n) / 1e6);
|
||||
|
||||
const seekTo = e => win.webContents.send("seekTo", microToSec(e.position));
|
||||
const seekBy = o => win.webContents.send("seekBy", microToSec(o));
|
||||
|
||||
const player = setupMPRIS();
|
||||
|
||||
ipcMain.on('seeked', (_, t) => player.seeked(secToMicro(t)));
|
||||
|
||||
let currentSeconds = 0;
|
||||
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
|
||||
|
||||
player.getPosition = () => secToMicro(currentSeconds)
|
||||
|
||||
player.on("raise", () => {
|
||||
win.setSkipTaskbar(false);
|
||||
win.show();
|
||||
});
|
||||
|
||||
player.on("play", () => {
|
||||
if (player.playbackStatus !== 'Playing') {
|
||||
player.playbackStatus = 'Playing';
|
||||
playPause()
|
||||
}
|
||||
});
|
||||
player.on("pause", () => {
|
||||
if (player.playbackStatus !== 'Paused') {
|
||||
player.playbackStatus = 'Paused';
|
||||
playPause()
|
||||
}
|
||||
});
|
||||
|
||||
player.on("playpause", playPause);
|
||||
player.on("next", next);
|
||||
player.on("previous", previous);
|
||||
|
||||
player.on('seek', seekBy);
|
||||
player.on('position', seekTo);
|
||||
|
||||
registerCallback(songInfo => {
|
||||
if (player) {
|
||||
const data = {
|
||||
'mpris:length': secToMicro(songInfo.songDuration),
|
||||
'mpris:artUrl': songInfo.imageSrc,
|
||||
'xesam:title': songInfo.title,
|
||||
'xesam:artist': songInfo.artist,
|
||||
'mpris:trackid': '/'
|
||||
};
|
||||
if (songInfo.album) data['xesam:album'] = songInfo.album;
|
||||
player.metadata = data;
|
||||
player.seeked(secToMicro(songInfo.elapsedSeconds))
|
||||
player.playbackStatus = songInfo.isPaused ? "Paused" : "Playing"
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e) {
|
||||
console.warn("Error in MPRIS", e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = registerMPRIS;
|
||||
37
plugins/skip-silences/front.js
Normal file
37
plugins/skip-silences/front.js
Normal file
@ -0,0 +1,37 @@
|
||||
const hark = require("hark/hark.bundle.js");
|
||||
|
||||
module.exports = () => {
|
||||
let isSilent = false;
|
||||
|
||||
document.addEventListener("apiLoaded", (apiEvent) => {
|
||||
const video = document.querySelector("video");
|
||||
const speechEvents = hark(video, {
|
||||
threshold: -100, // dB (-100 = absolute silence, 0 = loudest)
|
||||
interval: 2, // ms
|
||||
});
|
||||
const skipSilence = () => {
|
||||
if (isSilent && !video.paused) {
|
||||
video.currentTime += 0.2; // in s
|
||||
}
|
||||
};
|
||||
|
||||
speechEvents.on("speaking", function () {
|
||||
isSilent = false;
|
||||
});
|
||||
|
||||
speechEvents.on("stopped_speaking", function () {
|
||||
if (!(video.paused || video.seeking || video.ended)) {
|
||||
isSilent = true;
|
||||
skipSilence();
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener("play", function () {
|
||||
skipSilence();
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", function () {
|
||||
skipSilence();
|
||||
});
|
||||
});
|
||||
};
|
||||
51
plugins/sponsorblock/back.js
Normal file
51
plugins/sponsorblock/back.js
Normal file
@ -0,0 +1,51 @@
|
||||
const fetch = require("node-fetch");
|
||||
const is = require("electron-is");
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const defaultConfig = require("../../config/defaults");
|
||||
const { sortSegments } = require("./segments");
|
||||
|
||||
let videoID;
|
||||
|
||||
module.exports = (win, options) => {
|
||||
const { apiURL, categories } = {
|
||||
...defaultConfig.plugins.sponsorblock,
|
||||
...options,
|
||||
};
|
||||
|
||||
ipcMain.on("video-src-changed", async (_, data) => {
|
||||
videoID = JSON.parse(data)?.videoDetails?.videoId;
|
||||
const segments = await fetchSegments(apiURL, categories);
|
||||
win.webContents.send("sponsorblock-skip", segments);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const fetchSegments = async (apiURL, categories) => {
|
||||
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();
|
||||
const sortedSegments = sortSegments(
|
||||
segments.map((submission) => submission.segment)
|
||||
);
|
||||
|
||||
return sortedSegments;
|
||||
} catch (e) {
|
||||
if (is.dev()) {
|
||||
console.log('error on sponsorblock request:', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
31
plugins/sponsorblock/front.js
Normal file
31
plugins/sponsorblock/front.js
Normal file
@ -0,0 +1,31 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
const is = require("electron-is");
|
||||
|
||||
let currentSegments = [];
|
||||
|
||||
module.exports = () => {
|
||||
ipcRenderer.on("sponsorblock-skip", (_, segments) => {
|
||||
currentSegments = segments;
|
||||
});
|
||||
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
video.addEventListener('timeupdate', e => {
|
||||
currentSegments.forEach((segment) => {
|
||||
if (
|
||||
e.target.currentTime >= segment[0] &&
|
||||
e.target.currentTime < segment[1]
|
||||
) {
|
||||
e.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 })
|
||||
};
|
||||
29
plugins/sponsorblock/segments.js
Normal file
29
plugins/sponsorblock/segments.js
Normal file
@ -0,0 +1,29 @@
|
||||
// Segments are an array [ [start, end], … ]
|
||||
module.exports.sortSegments = (segments) => {
|
||||
segments.sort((segment1, segment2) =>
|
||||
segment1[0] === segment2[0]
|
||||
? segment1[1] - segment2[1]
|
||||
: segment1[0] - segment2[0]
|
||||
);
|
||||
|
||||
const compiledSegments = [];
|
||||
let currentSegment;
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (!currentSegment) {
|
||||
currentSegment = segment;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSegment[1] < segment[0]) {
|
||||
compiledSegments.push(currentSegment);
|
||||
currentSegment = segment;
|
||||
return;
|
||||
}
|
||||
|
||||
currentSegment[1] = Math.max(currentSegment[1], segment[1]);
|
||||
});
|
||||
compiledSegments.push(currentSegment);
|
||||
|
||||
return compiledSegments;
|
||||
};
|
||||
34
plugins/sponsorblock/tests/segments.test.js
Normal file
34
plugins/sponsorblock/tests/segments.test.js
Normal file
@ -0,0 +1,34 @@
|
||||
const { sortSegments } = require("../segments");
|
||||
|
||||
test("Segment sorting", () => {
|
||||
expect(
|
||||
sortSegments([
|
||||
[0, 3],
|
||||
[7, 8],
|
||||
[5, 6],
|
||||
])
|
||||
).toEqual([
|
||||
[0, 3],
|
||||
[5, 6],
|
||||
[7, 8],
|
||||
]);
|
||||
|
||||
expect(
|
||||
sortSegments([
|
||||
[0, 5],
|
||||
[6, 8],
|
||||
[4, 6],
|
||||
])
|
||||
).toEqual([[0, 8]]);
|
||||
|
||||
expect(
|
||||
sortSegments([
|
||||
[0, 6],
|
||||
[7, 8],
|
||||
[4, 6],
|
||||
])
|
||||
).toEqual([
|
||||
[0, 6],
|
||||
[7, 8],
|
||||
]);
|
||||
});
|
||||
BIN
plugins/taskbar-mediacontrol/assets/backward.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/backward.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
plugins/taskbar-mediacontrol/assets/forward.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/forward.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 B |
BIN
plugins/taskbar-mediacontrol/assets/pause.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/pause.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 B |
BIN
plugins/taskbar-mediacontrol/assets/play.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
53
plugins/taskbar-mediacontrol/back.js
Normal file
53
plugins/taskbar-mediacontrol/back.js
Normal file
@ -0,0 +1,53 @@
|
||||
const getSongControls = require('../../providers/song-controls');
|
||||
const registerCallback = require('../../providers/song-info');
|
||||
const path = require('path');
|
||||
|
||||
let controls;
|
||||
let currentSongInfo;
|
||||
|
||||
module.exports = win => {
|
||||
const { playPause, next, previous } = getSongControls(win);
|
||||
controls = { playPause, next, previous };
|
||||
|
||||
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)
|
||||
})
|
||||
};
|
||||
|
||||
function setThumbar(win, songInfo) {
|
||||
// Wait for song to start before setting thumbar
|
||||
if (!songInfo?.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Win32 require full rewrite of components
|
||||
win.setThumbarButtons([
|
||||
{
|
||||
tooltip: 'Previous',
|
||||
icon: get('backward.png'),
|
||||
click() { controls.previous(win.webContents); }
|
||||
}, {
|
||||
tooltip: 'Play/Pause',
|
||||
// Update icon based on play state
|
||||
icon: songInfo.isPaused ? get('play.png') : get('pause.png'),
|
||||
click() { controls.playPause(win.webContents); }
|
||||
}, {
|
||||
tooltip: 'Next',
|
||||
icon: get('forward.png'),
|
||||
click() { controls.next(win.webContents); }
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Util
|
||||
function get(file) {
|
||||
return path.join(__dirname, "assets", file);
|
||||
}
|
||||
@ -7,7 +7,7 @@ const {
|
||||
TouchBarScrubber,
|
||||
} = TouchBar;
|
||||
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
const getSongControls = require("../../providers/song-controls");
|
||||
|
||||
// Songtitle label
|
||||
@ -59,11 +59,10 @@ const touchBar = new TouchBar({
|
||||
});
|
||||
|
||||
module.exports = (win) => {
|
||||
const registerCallback = getSongInfo(win);
|
||||
const { playPause, next, previous, like, dislike } = getSongControls(win);
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.on("ready-to-show", () => {
|
||||
win.once("ready-to-show", () => {
|
||||
controls = [previous, playPause, next, like, dislike];
|
||||
|
||||
// Register the callback
|
||||
|
||||
52
plugins/tuna-obs/back.js
Normal file
52
plugins/tuna-obs/back.js
Normal file
@ -0,0 +1,52 @@
|
||||
const { ipcMain } = require("electron");
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const registerCallback = require("../../providers/song-info");
|
||||
|
||||
const secToMilisec = t => Math.round(Number(t) * 1e3);
|
||||
const data = {
|
||||
cover_url: '',
|
||||
title: '',
|
||||
artists: [],
|
||||
status: '',
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
album_url: '',
|
||||
album: undefined
|
||||
};
|
||||
|
||||
const post = async (data) => {
|
||||
const port = 1608;
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
const url = `http://localhost:${port}/`;
|
||||
fetch(url, { method: 'POST', headers, body: JSON.stringify({ data }) }).catch(e => console.log(`Error: '${e.code || e.errno}' - when trying to access obs-tuna webserver at port ${port}`));
|
||||
}
|
||||
|
||||
module.exports = async (win) => {
|
||||
ipcMain.on('timeChanged', async (_, t) => {
|
||||
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)
|
||||
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);
|
||||
})
|
||||
}
|
||||
@ -42,9 +42,12 @@ module.exports.fileExists = (path, callbackIfExists) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.injectCSS = (webContents, filepath) => {
|
||||
webContents.on("did-finish-load", () => {
|
||||
webContents.insertCSS(fs.readFileSync(filepath, "utf8"));
|
||||
module.exports.injectCSS = (webContents, filepath, cb = undefined) => {
|
||||
webContents.on("did-finish-load", async () => {
|
||||
await webContents.insertCSS(fs.readFileSync(filepath, "utf8"));
|
||||
if (cb) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
10
plugins/video-toggle/back.js
Normal file
10
plugins/video-toggle/back.js
Normal file
@ -0,0 +1,10 @@
|
||||
const { injectCSS } = require("../utils");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = (win, options) => {
|
||||
if (options.forceHide) {
|
||||
injectCSS(win.webContents, path.join(__dirname, "force-hide.css"));
|
||||
} else {
|
||||
injectCSS(win.webContents, path.join(__dirname, "button-switcher.css"));
|
||||
}
|
||||
};
|
||||
77
plugins/video-toggle/button-switcher.css
Normal file
77
plugins/video-toggle/button-switcher.css
Normal file
@ -0,0 +1,77 @@
|
||||
#main-panel.ytmusic-player-page {
|
||||
align-items: unset !important;
|
||||
}
|
||||
|
||||
.video-switch-button {
|
||||
z-index: 999;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin-top: 20px;
|
||||
margin-left: 10px;
|
||||
background: rgba(33, 33, 33, 0.4);
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
width: 240px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
padding-right: 120px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.video-switch-button:before {
|
||||
content: "Video";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-switch-button-checkbox {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.video-switch-button-label-span {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-switch-button-checkbox:checked+.video-switch-button-label:before {
|
||||
transform: translateX(120px);
|
||||
transition: transform 300ms linear;
|
||||
}
|
||||
|
||||
.video-switch-button-checkbox+.video-switch-button-label {
|
||||
position: relative;
|
||||
padding: 15px 0;
|
||||
display: block;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-switch-button-checkbox+.video-switch-button-label:before {
|
||||
content: "";
|
||||
background: rgba(60, 60, 60, 0.4);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 30px;
|
||||
transform: translateX(0);
|
||||
transition: transform 300ms;
|
||||
}
|
||||
116
plugins/video-toggle/front.js
Normal file
116
plugins/video-toggle/front.js
Normal file
@ -0,0 +1,116 @@
|
||||
const { ElementFromFile, templatePath } = require("../utils");
|
||||
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
|
||||
let options, player, video, api;
|
||||
|
||||
const switchButtonDiv = ElementFromFile(
|
||||
templatePath(__dirname, "button_template.html")
|
||||
);
|
||||
|
||||
module.exports = (_options) => {
|
||||
if (_options.forceHide) return;
|
||||
options = _options;
|
||||
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
|
||||
}
|
||||
|
||||
function setup(e) {
|
||||
api = e.detail;
|
||||
player = $('ytmusic-player');
|
||||
video = $('video');
|
||||
|
||||
$('ytmusic-player-page').prepend(switchButtonDiv);
|
||||
|
||||
$('#song-image.ytmusic-player').style.display = "block";
|
||||
|
||||
if (options.hideVideo) {
|
||||
$('.video-switch-button-checkbox').checked = false;
|
||||
changeDisplay(false);
|
||||
forcePlaybackMode();
|
||||
// fix black video
|
||||
video.style.height = "auto";
|
||||
}
|
||||
|
||||
// button checked = show video
|
||||
switchButtonDiv.addEventListener('change', (e) => {
|
||||
options.hideVideo = !e.target.checked;
|
||||
changeDisplay(e.target.checked);
|
||||
setOptions("video-toggle", options);
|
||||
})
|
||||
|
||||
video.addEventListener('srcChanged', videoStarted);
|
||||
|
||||
observeThumbnail();
|
||||
}
|
||||
|
||||
function changeDisplay(showVideo) {
|
||||
player.style.margin = showVideo ? '' : 'auto 0px';
|
||||
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
|
||||
$('#song-video.ytmusic-player').style.display = showVideo ? 'unset' : 'none';
|
||||
if (showVideo && !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') {
|
||||
// switch to high res thumbnail
|
||||
forceThumbnail($('#song-image img'));
|
||||
// show toggle button
|
||||
switchButtonDiv.style.display = "initial";
|
||||
// change display to video mode if video exist & video is hidden & option.hideVideo = false
|
||||
if (!options.hideVideo && $('#song-video.ytmusic-player').style.display === "none") {
|
||||
changeDisplay(true);
|
||||
} else {
|
||||
moveVolumeHud(!options.hideVideo);
|
||||
}
|
||||
} else {
|
||||
// video doesn't exist -> switch to song mode
|
||||
changeDisplay(false);
|
||||
// hide toggle button
|
||||
switchButtonDiv.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const playbackModeObserver = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.target.getAttribute('playback-mode') !== "ATV_PREFERRED") {
|
||||
playbackModeObserver.disconnect();
|
||||
mutation.target.setAttribute('playback-mode', "ATV_PREFERRED");
|
||||
}
|
||||
});
|
||||
});
|
||||
playbackModeObserver.observe(player, { attributeFilter: ["playback-mode"] });
|
||||
}
|
||||
|
||||
// if precise volume plugin is enabled, move its hud to be on top of the video
|
||||
function moveVolumeHud(showVideo) {
|
||||
const volumeHud = $('#volumeHud');
|
||||
if (volumeHud)
|
||||
volumeHud.style.top = showVideo ? `${(player.clientHeight - video.clientHeight) / 2}px` : 0;
|
||||
}
|
||||
|
||||
function observeThumbnail() {
|
||||
const playbackModeObserver = new MutationObserver(mutations => {
|
||||
if (!player.videoMode_) return;
|
||||
|
||||
mutations.forEach(mutation => {
|
||||
if (!mutation.target.src.startsWith('data:')) return;
|
||||
forceThumbnail(mutation.target)
|
||||
});
|
||||
});
|
||||
playbackModeObserver.observe($('#song-image img'), { attributeFilter: ["src"] })
|
||||
}
|
||||
|
||||
function forceThumbnail(img) {
|
||||
const thumbnails = $('#movie_player').getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails;
|
||||
if (thumbnails && thumbnails.length > 0) {
|
||||
img.src = thumbnails[thumbnails.length - 1].url.split("?")[0];
|
||||
}
|
||||
}
|
||||
13
plugins/video-toggle/menu.js
Normal file
13
plugins/video-toggle/menu.js
Normal file
@ -0,0 +1,13 @@
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
|
||||
module.exports = (win, options) => [
|
||||
{
|
||||
label: "Force Remove Video Tab",
|
||||
type: "checkbox",
|
||||
checked: options.forceHide,
|
||||
click: item => {
|
||||
options.forceHide = item.checked;
|
||||
setOptions("video-toggle", options);
|
||||
}
|
||||
}
|
||||
];
|
||||
4
plugins/video-toggle/templates/button_template.html
Normal file
4
plugins/video-toggle/templates/button_template.html
Normal file
@ -0,0 +1,4 @@
|
||||
<div class="video-switch-button">
|
||||
<input class="video-switch-button-checkbox" type="checkbox" checked="true"></input>
|
||||
<label class="video-switch-button-label" for=""><span class="video-switch-button-label-span">Song</span></label>
|
||||
</div>
|
||||
64
preload.js
64
preload.js
@ -1,16 +1,27 @@
|
||||
const path = require("path");
|
||||
|
||||
const { contextBridge, remote } = require("electron");
|
||||
const remote = require('@electron/remote');
|
||||
|
||||
const config = require("./config");
|
||||
const { fileExists } = require("./plugins/utils");
|
||||
const setupFrontLogger = require("./providers/front-logger");
|
||||
const setupSongInfo = require("./providers/song-info-front");
|
||||
const { setupSongControls } = require("./providers/song-controls-front");
|
||||
|
||||
const plugins = config.plugins.getEnabled();
|
||||
|
||||
let api;
|
||||
|
||||
plugins.forEach(([plugin, options]) => {
|
||||
const pluginPath = path.join(__dirname, "plugins", plugin, "actions.js");
|
||||
fileExists(pluginPath, () => {
|
||||
const actions = require(pluginPath).actions || {};
|
||||
const preloadPath = path.join(__dirname, "plugins", plugin, "preload.js");
|
||||
fileExists(preloadPath, () => {
|
||||
const run = require(preloadPath);
|
||||
run(options);
|
||||
});
|
||||
|
||||
const actionPath = path.join(__dirname, "plugins", plugin, "actions.js");
|
||||
fileExists(actionPath, () => {
|
||||
const actions = require(actionPath).actions || {};
|
||||
|
||||
// TODO: re-enable once contextIsolation is set to true
|
||||
// contextBridge.exposeInMainWorld(plugin + "Actions", actions);
|
||||
@ -29,7 +40,52 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// wait for complete load of youtube api
|
||||
listenForApiLoad();
|
||||
|
||||
// inject song-info provider
|
||||
setupSongInfo();
|
||||
|
||||
// inject song-controls
|
||||
setupSongControls();
|
||||
|
||||
// inject front logger
|
||||
setupFrontLogger();
|
||||
|
||||
// Add action for reloading
|
||||
global.reload = () =>
|
||||
remote.getCurrentWindow().webContents.loadURL(config.get("url"));
|
||||
|
||||
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
|
||||
setInterval(() => window._lact = Date.now(), 900000);
|
||||
});
|
||||
|
||||
function listenForApiLoad() {
|
||||
api = document.querySelector('#movie_player');
|
||||
if (api) {
|
||||
onApiLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
api = document.querySelector('#movie_player');
|
||||
if (api) {
|
||||
observer.disconnect();
|
||||
onApiLoaded();
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function onApiLoaded() {
|
||||
document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api }));
|
||||
|
||||
// Remove upgrade button
|
||||
if (config.get("options.removeUpgradeButton")) {
|
||||
const upgradeButtton = document.querySelector('ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]')
|
||||
if (upgradeButtton) {
|
||||
upgradeButtton.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,4 @@
|
||||
let domElements = {};
|
||||
const getSongMenu = () =>
|
||||
document.querySelector("ytmusic-menu-popup-renderer tp-yt-paper-listbox");
|
||||
|
||||
const watchDOMElement = (name, selectorFn, cb) => {
|
||||
const observer = new MutationObserver((mutations, observer) => {
|
||||
if (!domElements[name]) {
|
||||
domElements[name] = selectorFn(document);
|
||||
}
|
||||
|
||||
if (domElements[name]) {
|
||||
cb(domElements[name]);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { watchDOMElement };
|
||||
module.exports = { getSongMenu };
|
||||
|
||||
13
providers/front-logger.js
Normal file
13
providers/front-logger.js
Normal file
@ -0,0 +1,13 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
function logToString(log) {
|
||||
return (typeof log === "string") ?
|
||||
log :
|
||||
JSON.stringify(log, null, "\t");
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
ipcRenderer.on("log", (_event, log) => {
|
||||
console.log(logToString(log));
|
||||
});
|
||||
};
|
||||
14
providers/prompt-custom-titlebar.js
Normal file
14
providers/prompt-custom-titlebar.js
Normal file
@ -0,0 +1,14 @@
|
||||
const customTitlebar = require("custom-electron-titlebar");
|
||||
|
||||
module.exports = () => {
|
||||
new customTitlebar.Titlebar({
|
||||
backgroundColor: customTitlebar.Color.fromHex("#050505"),
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
menu: null
|
||||
});
|
||||
const mainStyle = document.querySelector("#container").style;
|
||||
mainStyle.width = "100%";
|
||||
mainStyle.position = "fixed";
|
||||
mainStyle.border = "unset";
|
||||
};
|
||||
18
providers/prompt-options.js
Normal file
18
providers/prompt-options.js
Normal file
@ -0,0 +1,18 @@
|
||||
const path = require("path");
|
||||
const is = require("electron-is");
|
||||
|
||||
const iconPath = path.join(__dirname, "..", "assets", "youtube-music-tray.png");
|
||||
const customTitlebarPath = path.join(__dirname, "prompt-custom-titlebar.js");
|
||||
|
||||
const promptOptions = is.macOS() ? {
|
||||
customStylesheet: "dark",
|
||||
icon: iconPath
|
||||
} : {
|
||||
customStylesheet: "dark",
|
||||
// The following are used for custom titlebar
|
||||
frame: false,
|
||||
customScript: customTitlebarPath,
|
||||
enableRemoteModule: true
|
||||
};
|
||||
|
||||
module.exports = () => promptOptions;
|
||||
13
providers/song-controls-front.js
Normal file
13
providers/song-controls-front.js
Normal file
@ -0,0 +1,13 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const config = require("../config");
|
||||
const is = require("electron-is");
|
||||
|
||||
module.exports.setupSongControls = () => {
|
||||
document.addEventListener('apiLoaded', e => {
|
||||
ipcRenderer.on("seekTo", (_, t) => e.detail.seekTo(t));
|
||||
ipcRenderer.on("seekBy", (_, t) => e.detail.seekBy(t));
|
||||
if (is.linux() && config.plugins.isEnabled('shortcuts')) { // MPRIS Enabled
|
||||
document.querySelector('video').addEventListener('seeked', v => ipcRenderer.send('seeked', v.target.currentTime));
|
||||
}
|
||||
}, { once: true, passive: true })
|
||||
};
|
||||
@ -13,8 +13,8 @@ module.exports = (win) => {
|
||||
previous: () => pressKey(win, "k"),
|
||||
next: () => pressKey(win, "j"),
|
||||
playPause: () => pressKey(win, "space"),
|
||||
like: () => pressKey(win, "_"),
|
||||
dislike: () => pressKey(win, "+"),
|
||||
like: () => pressKey(win, "+"),
|
||||
dislike: () => pressKey(win, "_"),
|
||||
go10sBack: () => pressKey(win, "h"),
|
||||
go10sForward: () => pressKey(win, "l"),
|
||||
go1sBack: () => pressKey(win, "h", ["shift"]),
|
||||
@ -24,8 +24,6 @@ module.exports = (win) => {
|
||||
// General
|
||||
volumeMinus10: () => pressKey(win, "-"),
|
||||
volumePlus10: () => pressKey(win, "="),
|
||||
dislikeAndNext: () => pressKey(win, "-", ["shift"]),
|
||||
like: () => pressKey(win, "=", ["shift"]),
|
||||
fullscreen: () => pressKey(win, "f"),
|
||||
muteUnmute: () => pressKey(win, "m"),
|
||||
maximizeMinimisePlayer: () => pressKey(win, "q"),
|
||||
@ -38,14 +36,14 @@ module.exports = (win) => {
|
||||
pressKey(win, "g");
|
||||
pressKey(win, "l");
|
||||
},
|
||||
goToHotlist: () => {
|
||||
pressKey(win, "g");
|
||||
pressKey(win, "t");
|
||||
},
|
||||
goToSettings: () => {
|
||||
pressKey(win, "g");
|
||||
pressKey(win, ",");
|
||||
},
|
||||
goToExplore: () => {
|
||||
pressKey(win, "g");
|
||||
pressKey(win, "e");
|
||||
},
|
||||
search: () => pressKey(win, "/"),
|
||||
showShortcuts: () => pressKey(win, "/", ["shift"]),
|
||||
};
|
||||
|
||||
60
providers/song-info-front.js
Normal file
60
providers/song-info-front.js
Normal file
@ -0,0 +1,60 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
const is = require('electron-is');
|
||||
const { getImage } = require("./song-info");
|
||||
|
||||
const config = require("../config");
|
||||
|
||||
global.songInfo = {};
|
||||
|
||||
function $(selector) { return document.querySelector(selector); }
|
||||
|
||||
ipcRenderer.on("update-song-info", async (_, extractedSongInfo) => {
|
||||
global.songInfo = JSON.parse(extractedSongInfo);
|
||||
global.songInfo.image = await getImage(global.songInfo.imageSrc);
|
||||
});
|
||||
|
||||
// used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473)
|
||||
const srcChangedEvent = new CustomEvent('srcChanged');
|
||||
|
||||
module.exports = () => {
|
||||
document.addEventListener('apiLoaded', apiEvent => {
|
||||
if (config.plugins.isEnabled('tuna-obs') ||
|
||||
(is.linux() && config.plugins.isEnabled('shortcuts'))) {
|
||||
setupTimeChangeListener();
|
||||
}
|
||||
const video = $('video');
|
||||
// name = "dataloaded" and abit later "dataupdated"
|
||||
apiEvent.detail.addEventListener('videodatachange', (name, _dataEvent) => {
|
||||
if (name !== 'dataloaded') return;
|
||||
video.dispatchEvent(srcChangedEvent);
|
||||
sendSongInfo();
|
||||
})
|
||||
|
||||
for (const status of ['playing', 'pause']) {
|
||||
video.addEventListener(status, e => {
|
||||
if (Math.round(e.target.currentTime) > 0) {
|
||||
ipcRenderer.send("playPaused", {
|
||||
isPaused: status === 'pause',
|
||||
elapsedSeconds: Math.floor(e.target.currentTime)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendSongInfo() {
|
||||
const data = apiEvent.detail.getPlayerResponse();
|
||||
data.videoDetails.album = $('ytmusic-player-page')?.__data?.playerPageWatchMetadata?.albumName?.runs[0].text
|
||||
data.videoDetails.elapsedSeconds = Math.floor(video.currentTime);
|
||||
data.videoDetails.isPaused = false;
|
||||
ipcRenderer.send("video-src-changed", JSON.stringify(data));
|
||||
}
|
||||
}, { once: true, passive: true });
|
||||
};
|
||||
|
||||
function setupTimeChangeListener() {
|
||||
const progressObserver = new MutationObserver(mutations => {
|
||||
ipcRenderer.send('timeChanged', mutations[0].target.value);
|
||||
global.songInfo.elapsedSeconds = mutations[0].target.value;
|
||||
});
|
||||
progressObserver.observe($('#progress-bar'), { attributeFilter: ["value"] })
|
||||
}
|
||||
@ -1,137 +1,129 @@
|
||||
const { nativeImage } = require("electron");
|
||||
const { ipcMain, nativeImage } = require("electron");
|
||||
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
// This selects the song title
|
||||
const titleSelector = ".title.style-scope.ytmusic-player-bar";
|
||||
const config = require("../config");
|
||||
|
||||
// This selects the song image
|
||||
const imageSelector =
|
||||
"#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > img";
|
||||
|
||||
// This selects the song subinfo, this includes artist, views, likes
|
||||
const subInfoSelector =
|
||||
"#layout > ytmusic-player-bar > div.middle-controls.style-scope.ytmusic-player-bar > div.content-info-wrapper.style-scope.ytmusic-player-bar > span";
|
||||
|
||||
// This selects the progress bar, used for songlength and current progress
|
||||
const progressSelector = "#progress-bar";
|
||||
|
||||
// Grab the title using the selector
|
||||
const getTitle = (win) => {
|
||||
return win.webContents
|
||||
.executeJavaScript(
|
||||
"document.querySelector('" + titleSelector + "').innerText"
|
||||
)
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Grab the image src using the selector
|
||||
const getImageSrc = (win) => {
|
||||
return win.webContents
|
||||
.executeJavaScript("document.querySelector('" + imageSelector + "').src")
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Grab the subinfo using the selector
|
||||
const getSubInfo = async (win) => {
|
||||
// Get innerText of subinfo element
|
||||
const subInfoString = await win.webContents.executeJavaScript(
|
||||
'document.querySelector("' + subInfoSelector + '").innerText'
|
||||
);
|
||||
|
||||
// Split and clean the string
|
||||
const splittedSubInfo = subInfoString.replaceAll("\n", "").split(" • ");
|
||||
|
||||
// Make sure we always return 3 elements in the aray
|
||||
const subInfo = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Fill array with empty string if not defined
|
||||
subInfo.push(splittedSubInfo[i] || "");
|
||||
}
|
||||
|
||||
return subInfo;
|
||||
};
|
||||
|
||||
// Grab the progress using the selector
|
||||
const getProgress = async (win) => {
|
||||
// Get max value of the progressbar element
|
||||
const songDuration = await win.webContents.executeJavaScript(
|
||||
'document.querySelector("' + progressSelector + '").max'
|
||||
);
|
||||
// Get current value of the progressbar element
|
||||
const elapsedSeconds = await win.webContents.executeJavaScript(
|
||||
'document.querySelector("' + progressSelector + '").value'
|
||||
);
|
||||
|
||||
return { songDuration, elapsedSeconds };
|
||||
// Fill songInfo with empty values
|
||||
/**
|
||||
* @typedef {songInfo} SongInfo
|
||||
*/
|
||||
const songInfo = {
|
||||
title: "",
|
||||
artist: "",
|
||||
views: 0,
|
||||
uploadDate: "",
|
||||
imageSrc: "",
|
||||
image: null,
|
||||
isPaused: undefined,
|
||||
songDuration: 0,
|
||||
elapsedSeconds: 0,
|
||||
url: "",
|
||||
album: undefined,
|
||||
videoId: "",
|
||||
playlistId: "",
|
||||
};
|
||||
|
||||
// Grab the native image using the src
|
||||
const getImage = async (src) => {
|
||||
const result = await fetch(src);
|
||||
const buffer = await result.buffer();
|
||||
return nativeImage.createFromBuffer(buffer);
|
||||
const output = nativeImage.createFromBuffer(buffer);
|
||||
if (output.isEmpty() && !src.endsWith(".jpg") && src.includes(".jpg")) { // fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315)
|
||||
return getImage(src.slice(0, src.lastIndexOf(".jpg") + 4));
|
||||
} else {
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
const getPausedStatus = async (win) => {
|
||||
const title = await win.webContents.executeJavaScript("document.title");
|
||||
return !title.includes("-");
|
||||
};
|
||||
const handleData = async (responseText, win) => {
|
||||
const data = JSON.parse(responseText);
|
||||
if (!data) return;
|
||||
|
||||
// Fill songInfo with empty values
|
||||
const songInfo = {
|
||||
title: "",
|
||||
artist: "",
|
||||
views: "",
|
||||
likes: "",
|
||||
imageSrc: "",
|
||||
image: null,
|
||||
isPaused: true,
|
||||
songDuration: 0,
|
||||
elapsedSeconds: 0,
|
||||
};
|
||||
const microformat = data.microformat?.microformatDataRenderer;
|
||||
if (microformat) {
|
||||
songInfo.uploadDate = microformat.uploadDate;
|
||||
songInfo.url = microformat.urlCanonical?.split("&")[0];
|
||||
songInfo.playlistId = new URL(microformat.urlCanonical).searchParams.get("list");
|
||||
// used for options.resumeOnStart
|
||||
config.set("url", microformat.urlCanonical);
|
||||
}
|
||||
|
||||
const registerProvider = (win) => {
|
||||
// This variable will be filled with the callbacks once they register
|
||||
const callbacks = [];
|
||||
const videoDetails = data.videoDetails;
|
||||
if (videoDetails) {
|
||||
songInfo.title = cleanupName(videoDetails.title);
|
||||
songInfo.artist = cleanupName(videoDetails.author);
|
||||
songInfo.views = videoDetails.viewCount;
|
||||
songInfo.songDuration = videoDetails.lengthSeconds;
|
||||
songInfo.elapsedSeconds = videoDetails.elapsedSeconds;
|
||||
songInfo.isPaused = videoDetails.isPaused;
|
||||
songInfo.videoId = videoDetails.videoId;
|
||||
songInfo.album = data?.videoDetails?.album; // Will be undefined if video exist
|
||||
|
||||
// This function will allow plugins to register callback that will be triggered when data changes
|
||||
const registerCallback = (callback) => {
|
||||
callbacks.push(callback);
|
||||
};
|
||||
|
||||
win.on("page-title-updated", async () => {
|
||||
// Save the old title temporarily
|
||||
const oldTitle = songInfo.title;
|
||||
// Get and set the new data
|
||||
songInfo.title = await getTitle(win);
|
||||
songInfo.isPaused = await getPausedStatus(win);
|
||||
|
||||
const { songDuration, elapsedSeconds } = await getProgress(win);
|
||||
songInfo.songDuration = songDuration;
|
||||
songInfo.elapsedSeconds = elapsedSeconds;
|
||||
|
||||
// If title changed then we do need to update other info
|
||||
if (oldTitle !== songInfo.title) {
|
||||
const subInfo = await getSubInfo(win);
|
||||
songInfo.artist = subInfo[0];
|
||||
songInfo.views = subInfo[1];
|
||||
songInfo.likes = subInfo[2];
|
||||
songInfo.imageSrc = await getImageSrc(win);
|
||||
const oldUrl = songInfo.imageSrc;
|
||||
songInfo.imageSrc = videoDetails.thumbnail?.thumbnails?.pop()?.url.split("?")[0];
|
||||
if (oldUrl !== songInfo.imageSrc) {
|
||||
songInfo.image = await getImage(songInfo.imageSrc);
|
||||
}
|
||||
|
||||
// Trigger the callbacks
|
||||
win.webContents.send("update-song-info", JSON.stringify(songInfo));
|
||||
}
|
||||
};
|
||||
|
||||
// This variable will be filled with the callbacks once they register
|
||||
const callbacks = [];
|
||||
|
||||
// This function will allow plugins to register callback that will be triggered when data changes
|
||||
/**
|
||||
* @callback songInfoCallback
|
||||
* @param {songInfo} songInfo
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* @param {songInfoCallback} callback
|
||||
*/
|
||||
const registerCallback = (callback) => {
|
||||
callbacks.push(callback);
|
||||
};
|
||||
|
||||
const registerProvider = (win) => {
|
||||
// This will be called when the song-info-front finds a new request with song data
|
||||
ipcMain.on("video-src-changed", async (_, responseText) => {
|
||||
await handleData(responseText, win);
|
||||
callbacks.forEach((c) => {
|
||||
c(songInfo);
|
||||
});
|
||||
});
|
||||
|
||||
return registerCallback;
|
||||
ipcMain.on("playPaused", (_, { isPaused, elapsedSeconds }) => {
|
||||
songInfo.isPaused = isPaused;
|
||||
songInfo.elapsedSeconds = elapsedSeconds;
|
||||
callbacks.forEach((c) => {
|
||||
c(songInfo);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = registerProvider;
|
||||
const suffixesToRemove = [
|
||||
" - topic",
|
||||
"vevo",
|
||||
" (performance video)",
|
||||
" (official music video)",
|
||||
" (official video)",
|
||||
" (clip officiel)",
|
||||
];
|
||||
|
||||
function cleanupName(name) {
|
||||
if (!name) return name;
|
||||
const lowCaseName = name.toLowerCase();
|
||||
for (const suffix of suffixesToRemove) {
|
||||
if (lowCaseName.endsWith(suffix)) {
|
||||
return name.slice(0, -suffix.length);
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
module.exports = registerCallback;
|
||||
module.exports.setupSongInfo = registerProvider;
|
||||
module.exports.getImage = getImage;
|
||||
module.exports.cleanupName = cleanupName;
|
||||
|
||||
30
readme.md
30
readme.md
@ -34,16 +34,32 @@ You can check out the [latest release](https://github.com/th-ch/youtube-music/re
|
||||
Install the `youtube-music-bin` package from the AUR. For AUR installation instructions, take a look at this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Installing_packages).
|
||||
|
||||
## Available plugins:
|
||||
|
||||
- **Ad Blocker**: block all ads and tracking out of the box
|
||||
- **Downloader**: download to MP3 directly from the interface (youtube-dl)
|
||||
- **No Google Login**: remove Google login buttons and links from the interface
|
||||
- **Shortcuts**: use your usual shortcuts (media keys, Ctrl/CMD + F…) to control YouTube Music
|
||||
- **Audio compressor**: apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)
|
||||
- **Blur navigation bar**: makes navigation bar transparent and blurry
|
||||
- **Disable autoplay**: makes every song start in "paused" mode
|
||||
- [**Discord**](https://discord.com/): show your friends what you listen to with [Rich Presence](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)
|
||||
- **Downloader**: downloads MP3 [directly from the interface](https://user-images.githubusercontent.com/61631665/129977677-83a7d067-c192-45e1-98ae-b5a4927393be.png) [(youtube-dl)](https://github.com/ytdl-org/youtube-dl)
|
||||
- **In-app menu**: [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 accessing the menu after enabling this plugin and hide-menu option)
|
||||
- [**Last.fm**](https://www.last.fm/): scrobbles support
|
||||
- **Navigation**: next/back navigation arrows directly integrated in the interface, like in your favorite browser
|
||||
- **Auto confirm when paused**: when the "Continue Watching?" modal appears, automatically click "Yes"
|
||||
- **Hide video player**: no video in the interface when playing music
|
||||
- **Notifications**: display a notification when a song starts playing
|
||||
- **No Google Login**: remove Google login buttons and links from the interface
|
||||
- **Notifications**: display a notification when a song starts playing ([interactive notifications](https://user-images.githubusercontent.com/78568641/114102651-63ce0e00-98d0-11eb-9dfe-c5a02bb54f9c.png) are available on windows)
|
||||
- **Playback speed**: listen fast, listen slow! [Adds a slider that controls song speed](https://user-images.githubusercontent.com/61631665/129976003-e55db5ba-bf42-448c-a059-26a009775e68.png)
|
||||
- **Precise volume**: customizable volume steps for more comfort, allows controlling the volume precisely using mousewheel
|
||||
- **Quality changer**: Allows changing the video quality with a [button](https://user-images.githubusercontent.com/78568641/138574366-70324a5e-2d64-4f6a-acdd-dc2a2b9cecc5.png) on the video overlay
|
||||
- **Shortcuts**: Allows setting global hotkeys for playback (play/pause/next/previous) + disable [media osd](https://user-images.githubusercontent.com/84923831/128601225-afa38c1f-dea8-4209-9f72-0f84c1dd8b54.png) by overriding media keys + enable Ctrl/CMD + F to search + enable linux mpris support for mediakeys + [custom hotkeys](https://github.com/Araxeus/youtube-music/blob/1e591d6a3df98449bcda6e63baab249b28026148/providers/song-controls.js#L13-L50) for [advanced users](https://github.com/th-ch/youtube-music/issues/106#issuecomment-952156902)
|
||||
- [**SponsorBlock**](https://github.com/ajayyy/SponsorBlock): skips non-music parts
|
||||
- **Taskbar media control**: control playback from your [Windows taskbar](https://user-images.githubusercontent.com/78568641/111916130-24a35e80-8a82-11eb-80c8-5021c1aa27f4.png)
|
||||
- **Touchbar**: custom TouchBar layout for macOS
|
||||
- **Video Toggle**: Adds a button to switch between Video/Song mode. can also optionally remove the whole video tab
|
||||
|
||||
---
|
||||
|
||||
- **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 using `Hide Menu` option - you can show the menu with the `alt` key (or `escape` if using the in-app-menu plugin)
|
||||
|
||||
## Dev
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
const path = require("path");
|
||||
|
||||
const getPort = require("get-port");
|
||||
const NodeEnvironment = require("jest-environment-node");
|
||||
const electronPath = require("electron");
|
||||
const { Application } = require("spectron");
|
||||
const { _electron: electron } = require("playwright");
|
||||
|
||||
class TestEnvironment extends NodeEnvironment {
|
||||
constructor(config) {
|
||||
@ -14,21 +12,12 @@ class TestEnvironment extends NodeEnvironment {
|
||||
await super.setup();
|
||||
|
||||
const appPath = path.resolve(__dirname, "..");
|
||||
const port = await getPort();
|
||||
|
||||
this.global.__APP__ = new Application({
|
||||
path: electronPath,
|
||||
args: [appPath],
|
||||
port,
|
||||
});
|
||||
await this.global.__APP__.start();
|
||||
const { client } = this.global.__APP__;
|
||||
await client.waitUntilWindowLoaded();
|
||||
this.global.__APP__ = await electron.launch({ args: [appPath] });
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
if (this.global.__APP__.isRunning()) {
|
||||
await this.global.__APP__.stop();
|
||||
if (this.global.__APP__) {
|
||||
await this.global.__APP__.close();
|
||||
}
|
||||
await super.teardown();
|
||||
}
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
/**
|
||||
* @jest-environment ./tests/environment
|
||||
*/
|
||||
|
||||
describe("YouTube Music App", () => {
|
||||
const app = global.__APP__;
|
||||
|
||||
test("With default settings, app is launched and visible", async () => {
|
||||
expect(app.isRunning()).toBe(true);
|
||||
|
||||
const win = app.browserWindow;
|
||||
|
||||
const isMenuVisible = await win.isMenuBarVisible();
|
||||
expect(isMenuVisible).toBe(true);
|
||||
|
||||
const isVisible = await win.isVisible();
|
||||
expect(isVisible).toBe(true);
|
||||
|
||||
const { width, height } = await win.getBounds();
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
|
||||
const { client } = app;
|
||||
const title = await client.getTitle();
|
||||
const window = await app.firstWindow();
|
||||
const title = await window.title();
|
||||
expect(title).toEqual("YouTube Music");
|
||||
|
||||
const url = window.url();
|
||||
expect(url.startsWith("https://music.youtube.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
12
tray.js
12
tray.js
@ -3,7 +3,6 @@ const path = require("path");
|
||||
const { Menu, nativeImage, Tray } = require("electron");
|
||||
|
||||
const config = require("./config");
|
||||
const { mainMenuTemplate } = require("./menu");
|
||||
const getSongControls = require("./providers/song-controls");
|
||||
|
||||
// Prevent tray being garbage collected
|
||||
@ -32,7 +31,7 @@ module.exports.setUpTray = (app, win) => {
|
||||
}
|
||||
});
|
||||
|
||||
const trayMenu = Menu.buildFromTemplate([
|
||||
let template = [
|
||||
{
|
||||
label: "Play/Pause",
|
||||
click: () => {
|
||||
@ -57,13 +56,16 @@ module.exports.setUpTray = (app, win) => {
|
||||
win.show();
|
||||
},
|
||||
},
|
||||
...mainMenuTemplate(win),
|
||||
{
|
||||
label: "Quit",
|
||||
label: "Restart App",
|
||||
click: () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
{ role: "quit" }
|
||||
];
|
||||
|
||||
const trayMenu = Menu.buildFromTemplate(template);
|
||||
tray.setContextMenu(trayMenu);
|
||||
};
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
/* Allow window dragging */
|
||||
ytmusic-nav-bar {
|
||||
-webkit-user-select: none;
|
||||
-webkit-app-region : drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
iron-icon,
|
||||
@ -17,14 +17,25 @@ a {
|
||||
|
||||
/* custom style for navbar */
|
||||
ytmusic-app-layout {
|
||||
--ytmusic-nav-bar-height: 85px;
|
||||
--ytmusic-nav-bar-height: 90px;
|
||||
}
|
||||
|
||||
ytmusic-search-box.ytmusic-nav-bar {
|
||||
margin-top: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Blocking annoying elements */
|
||||
ytmusic-mealbar-promo-renderer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Disable Image Selection */
|
||||
img {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hide cast button which doesn't work */
|
||||
ytmusic-cast-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user