Compare commits

...

51 Commits

Author SHA1 Message Date
9eeb1c986a release 3.3.2 2024-02-20 20:59:48 +09:00
d37cd2418c fix: fix bugs in MPRIS, and improve MPRIS (#1760)
Co-authored-by: JellyBrick <shlee1503@naver.com>
Co-authored-by: Totto <32566573+Totto16@users.noreply.github.com>
2024-02-20 20:50:55 +09:00
8bd05f525d chore(deps): rollback dependency electron-builder to v24.9.1 2024-02-20 15:24:27 +09:00
47b23b414c chore(deps): update dependency electron-builder to v24.13.1 2024-02-20 14:43:04 +09:00
6f70d179c7 fix: fixed an issue that caused infinite loops when using Music Together
fix #1752
2024-02-20 12:57:49 +09:00
62a86e9267 fix(deps): update dependency electron-updater to v6.1.8 (#1770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 12:55:26 +09:00
6358a2d0b1 chore(deps): update dependency electron-builder to v24.12.0 (#1771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 12:55:17 +09:00
273633c2ce chore(i18n): Translated using Weblate (Korean)
Currently translated at 100.0% (342 of 342 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2024-02-20 04:15:04 +01:00
8b1209ef73 chore(i18n): Translated using Weblate (Italian)
Currently translated at 100.0% (340 of 340 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2024-02-20 03:55:03 +01:00
47505e9748 chore(i18n): Translated using Weblate (German)
Currently translated at 100.0% (340 of 340 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2024-02-20 03:55:03 +01:00
5178cc6bd8 feat(scrobblers): use BrowserWindow instead of shell.openExternal (#1758) 2024-02-20 11:54:57 +09:00
d9a27fff42 chore(deps): update dependency @typescript-eslint/eslint-plugin to v7.0.2 (#1763)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-20 11:54:37 +09:00
9e6560b814 chore(i18n): Translated using Weblate (Italian)
Currently translated at 99.7% (339 of 340 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2024-02-19 19:01:59 +01:00
afdb19a742 chore(deps): update dependency esbuild to v0.20.1 (#1759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-19 18:23:04 +09:00
0ae5b668f5 fix(in-app-menu): default config 2024-02-19 17:06:01 +09:00
10533e28fa fix: error.html path 2024-02-19 16:53:32 +09:00
6189e67819 fix(deps): update dependency i18next to v23.9.0 (#1754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-19 14:29:22 +09:00
f9ad505e40 fix: scrobbler migration 2024-02-19 14:27:24 +09:00
9b011101ed Update changelog for v3.3.1 2024-02-18 12:43:37 +00:00
a6ed8bf3aa release 3.3.1 (HOTFIX) 2024-02-18 21:36:04 +09:00
87acf4cf04 hotfix: in-app-menu position issue 2024-02-18 21:35:41 +09:00
b6fe2afd75 chore: Update README.md 2024-02-18 20:57:38 +09:00
6d9bb8eb1c Update changelog for v3.3.0 2024-02-18 11:40:17 +00:00
192fd0620f release 3.3.0 2024-02-18 20:32:45 +09:00
00d0b31980 fix(scrobbler): remove snake_case 2024-02-18 20:21:19 +09:00
5edb2131d2 fix(in-app-menu): implement auto close 2024-02-18 20:00:52 +09:00
4657aeca45 fix: apply fix from eslint 2024-02-18 11:58:26 +09:00
9f5651a8ba fix: fix upgrade button
fix #1199
2024-02-18 10:50:36 +09:00
570dcfee29 fix: revert fbc02a494a 2024-02-18 10:50:15 +09:00
2c130ce80d chore(mpris): use override keyword 2024-02-18 10:34:29 +09:00
8457115105 fix(mpris): fix mpris invalid position
- fix #1726
2024-02-18 10:26:45 +09:00
fbc02a494a fix(in-app-menu): simplified if-statement 2024-02-18 09:50:20 +09:00
7e2c254ecf fix(in-app-menu): should be disabled by default in linux env 2024-02-18 09:47:05 +09:00
11936a889e fix(deps): update dependency i18next to v23.8.3 (#1751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-18 07:33:35 +09:00
f33970addd import fixed ./constants (#1748) 2024-02-18 07:33:11 +09:00
2c02b7193d chore(deps): update dependency rollup to v4.12.0 (#1743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-17 10:08:33 +09:00
3f80b598ae chore(deps): bump undici from 5.28.2 to 5.28.3 (#1747)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-17 10:07:19 +09:00
477068ed49 chore(deps): update dependency vite to v5.1.3 (#1742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-16 08:44:31 +09:00
b4083874ac fix: invalid podcast artist name
🤦
2024-02-16 08:41:00 +09:00
c5d0673b2f chore(deps): update dependency vite-plugin-solid to v2.10.1 (#1734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:32:18 +09:00
9ccc44474b chore(deps): update dependency discord-api-types to v0.37.70 (#1740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:31:50 +09:00
98e341f122 chore(deps): update dependency electron to v28.2.3 (#1736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:29:11 +09:00
b23eba51dd chore(deps): update pnpm to v8.15.3 (#1739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:28:47 +09:00
980005a58c chore(deps): update dependency rollup to v4.11.0 (#1738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:28:41 +09:00
aba58b1d34 fix(deps): update dependency solid-js to v1.8.15 (#1735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 22:28:17 +09:00
bb6a127d22 chore(deps): update dependency vite to v5.1.2 (#1733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-15 20:32:18 +09:00
ac6e30a6b6 chore(i18n): Translated using Weblate (Polish)
Currently translated at 98.2% (334 of 340 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2024-02-14 19:01:58 +01:00
3f23282eed chore(deps): update dependency vite-plugin-solid to v2.10.0 (#1732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-14 20:36:09 +09:00
199a77819c chore(deps): update pnpm to v8.15.2 (#1729)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-14 01:16:33 +09:00
89a53b2854 Update Copyright - 2024 (#1730) 2024-02-14 01:06:26 +09:00
c57bf79b08 chore(i18n): Translated using Weblate (Indonesian)
Currently translated at 100.0% (340 of 340 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/id/
2024-02-12 23:42:44 +01:00
43 changed files with 1661 additions and 680 deletions

View File

@ -33,7 +33,7 @@ Read this in other languages: [🇰🇷](./docs/readme/README-ko.md)
| Player Screen (album color theme & ambient light) | | Player Screen (album color theme & ambient light) |
|:---------------------------------------------------------------------------------------------------------:| |:---------------------------------------------------------------------------------------------------------:|
|![Screenshot2](https://github.com/th-ch/youtube-music/assets/16558115/28ed8f08-c8c4-48ad-811b-7722093e9d81)| |![Screenshot1](https://github.com/th-ch/youtube-music/assets/16558115/53efdf73-b8fa-4d7b-a235-b96b91ea77fc)|
## Translation ## Translation

View File

@ -2,8 +2,114 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v3.3.1](https://github.com/th-ch/youtube-music/compare/v3.3.0...v3.3.1)
- Update changelog for v3.3.0 [`6d9bb8e`](https://github.com/th-ch/youtube-music/commit/6d9bb8eb1cc2d892a5552ffb1f7c20859aa80f67)
- hotfix: in-app-menu position issue [`87acf4c`](https://github.com/th-ch/youtube-music/commit/87acf4cf042ba32a000a4aeaec5c17c93501d333)
- release 3.3.1 (HOTFIX) [`a6ed8bf`](https://github.com/th-ch/youtube-music/commit/a6ed8bf3aa20ca8e950e85d88f981ccf9edc7498)
#### [v3.3.0](https://github.com/th-ch/youtube-music/compare/v3.2.2...v3.3.0)
> 18 February 2024
- fix(deps): update dependency i18next to v23.8.3 [`#1751`](https://github.com/th-ch/youtube-music/pull/1751)
- import fixed ./constants [`#1748`](https://github.com/th-ch/youtube-music/pull/1748)
- chore(deps): update dependency rollup to v4.12.0 [`#1743`](https://github.com/th-ch/youtube-music/pull/1743)
- chore(deps): bump undici from 5.28.2 to 5.28.3 [`#1747`](https://github.com/th-ch/youtube-music/pull/1747)
- chore(deps): update dependency vite to v5.1.3 [`#1742`](https://github.com/th-ch/youtube-music/pull/1742)
- chore(deps): update dependency vite-plugin-solid to v2.10.1 [`#1734`](https://github.com/th-ch/youtube-music/pull/1734)
- chore(deps): update dependency discord-api-types to v0.37.70 [`#1740`](https://github.com/th-ch/youtube-music/pull/1740)
- chore(deps): update dependency electron to v28.2.3 [`#1736`](https://github.com/th-ch/youtube-music/pull/1736)
- chore(deps): update pnpm to v8.15.3 [`#1739`](https://github.com/th-ch/youtube-music/pull/1739)
- chore(deps): update dependency rollup to v4.11.0 [`#1738`](https://github.com/th-ch/youtube-music/pull/1738)
- fix(deps): update dependency solid-js to v1.8.15 [`#1735`](https://github.com/th-ch/youtube-music/pull/1735)
- chore(deps): update dependency vite to v5.1.2 [`#1733`](https://github.com/th-ch/youtube-music/pull/1733)
- chore(deps): update dependency vite-plugin-solid to v2.10.0 [`#1732`](https://github.com/th-ch/youtube-music/pull/1732)
- chore(deps): update pnpm to v8.15.2 [`#1729`](https://github.com/th-ch/youtube-music/pull/1729)
- Update Copyright - 2024 [`#1730`](https://github.com/th-ch/youtube-music/pull/1730)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v7 [`#1728`](https://github.com/th-ch/youtube-music/pull/1728)
- fix(deps): update dependency @floating-ui/dom to v1.6.3 [`#1727`](https://github.com/th-ch/youtube-music/pull/1727)
- chore(deps): update dependency electron to v28.2.2 [`#1717`](https://github.com/th-ch/youtube-music/pull/1717)
- chore(deps): update dependency vite to v5.1.1 [`#1718`](https://github.com/th-ch/youtube-music/pull/1718)
- chore(deps): update dependency @types/semver to v7.5.7 [`#1724`](https://github.com/th-ch/youtube-music/pull/1724)
- fix(deps): update dependency @floating-ui/dom to v1.6.2 [`#1725`](https://github.com/th-ch/youtube-music/pull/1725)
- chore(deps): update dependency rollup to v4.10.0 [`#1719`](https://github.com/th-ch/youtube-music/pull/1719)
- fix(deps): update dependency solid-js to v1.8.14 [`#1713`](https://github.com/th-ch/youtube-music/pull/1713)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.21.0 [`#1711`](https://github.com/th-ch/youtube-music/pull/1711)
- fix(deps): update dependency semver to v7.6.0 [`#1712`](https://github.com/th-ch/youtube-music/pull/1712)
- refactor(in-app-menu): refactor `in-app-menu` plugin [`#1710`](https://github.com/th-ch/youtube-music/pull/1710)
- chore(deps): update playwright monorepo to v1.41.2 [`#1706`](https://github.com/th-ch/youtube-music/pull/1706)
- chore(deps): update dependency electron to v29.0.0-beta.5 [`#1707`](https://github.com/th-ch/youtube-music/pull/1707)
- feat(album-color-theme): support album color theme in all pages [`#1685`](https://github.com/th-ch/youtube-music/pull/1685)
- fix(deps): update dependency youtubei.js to v9.0.2 [`#1704`](https://github.com/th-ch/youtube-music/pull/1704)
- fix(deps): update dependency i18next to v23.8.2 [`#1702`](https://github.com/th-ch/youtube-music/pull/1702)
- feat: Support disabling scrobbling for non-music content [`#1665`](https://github.com/th-ch/youtube-music/pull/1665)
- fix(deps): update dependency youtubei.js to v9 [`#1682`](https://github.com/th-ch/youtube-music/pull/1682)
- chore(deps): update dependency electron to v29.0.0-beta.4 [`#1698`](https://github.com/th-ch/youtube-music/pull/1698)
- fix(deps): update dependency i18next to v23.8.1 [`#1694`](https://github.com/th-ch/youtube-music/pull/1694)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.20.0 [`#1700`](https://github.com/th-ch/youtube-music/pull/1700)
- chore(deps): update pnpm to v8.15.1 [`#1699`](https://github.com/th-ch/youtube-music/pull/1699)
- chore(deps): update dependency esbuild to v0.20.0 [`#1691`](https://github.com/th-ch/youtube-music/pull/1691)
- chore(deps): update pnpm to v8.15.0 [`#1692`](https://github.com/th-ch/youtube-music/pull/1692)
- fix(deps): update dependency i18next to v23.7.20 [`#1684`](https://github.com/th-ch/youtube-music/pull/1684)
- chore(deps): update dependency electron to v29.0.0-beta.3 [`#1683`](https://github.com/th-ch/youtube-music/pull/1683)
- chore(deps): update dependency electron to v29.0.0-beta.2 [`#1681`](https://github.com/th-ch/youtube-music/pull/1681)
- chore(deps): update dependency rollup to v4.9.6 [`#1663`](https://github.com/th-ch/youtube-music/pull/1663)
- chore(deps): update dependency electron to v29.0.0-beta.1 [`#1670`](https://github.com/th-ch/youtube-music/pull/1670)
- fix(deps): update dependency i18next to v23.7.19 [`#1680`](https://github.com/th-ch/youtube-music/pull/1680)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.19.1 [`#1669`](https://github.com/th-ch/youtube-music/pull/1669)
- chore(deps): update pnpm to v8.14.3 [`#1668`](https://github.com/th-ch/youtube-music/pull/1668)
- chore(deps): update dependency vite-plugin-inspect to v0.8.3 [`#1672`](https://github.com/th-ch/youtube-music/pull/1672)
- chore(deps): update dependency esbuild to v0.19.12 [`#1673`](https://github.com/th-ch/youtube-music/pull/1673)
- fix(deps): update dependency @electron/remote to v2.1.2 [`#1676`](https://github.com/th-ch/youtube-music/pull/1676)
- chore: Update issue templates [`#1661`](https://github.com/th-ch/youtube-music/pull/1661)
- chore(deps): update playwright monorepo to v1.41.1 [`#1660`](https://github.com/th-ch/youtube-music/pull/1660)
- fix(deps): update dependency i18next to v23.7.18 [`#1662`](https://github.com/th-ch/youtube-music/pull/1662)
- chore(deps): update actions/dependency-review-action action to v4 [`#1654`](https://github.com/th-ch/youtube-music/pull/1654)
- chore(deps): update dependency electron to v29.0.0-alpha.11 [`#1656`](https://github.com/th-ch/youtube-music/pull/1656)
- chore(deps): update dependency vite to v5.0.12 [security] [`#1659`](https://github.com/th-ch/youtube-music/pull/1659)
- fix(deps): update dependency async-mutex to v0.4.1 [`#1653`](https://github.com/th-ch/youtube-music/pull/1653)
- chore(deps): update playwright monorepo to v1.41.0 [`#1651`](https://github.com/th-ch/youtube-music/pull/1651)
- feat: Better Scrobbler Plugin [`#1640`](https://github.com/th-ch/youtube-music/pull/1640)
- chore(deps): update dependency electron to v29.0.0-alpha.10 [`#1645`](https://github.com/th-ch/youtube-music/pull/1645)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.19.0 [`#1643`](https://github.com/th-ch/youtube-music/pull/1643)
- chore(README): Fix plugins names and add plugins in/to Readme (in menu too) [`#1624`](https://github.com/th-ch/youtube-music/pull/1624)
- fix(album-actions): Fixed album actions [`#1639`](https://github.com/th-ch/youtube-music/pull/1639)
- chore(deps): update playwright monorepo to v1.41.0-beta-1705101589000 [`#1638`](https://github.com/th-ch/youtube-music/pull/1638)
- fix(#1543): fix song control doesn't work [`#1637`](https://github.com/th-ch/youtube-music/pull/1637)
- chore(deps): update playwright monorepo to v1.41.0-beta-1705092460000 [`#1635`](https://github.com/th-ch/youtube-music/pull/1635)
- chore(deps): update dependency rollup to v4.9.5 [`#1629`](https://github.com/th-ch/youtube-music/pull/1629)
- chore(deps): update dependency electron to v29.0.0-alpha.9 [`#1627`](https://github.com/th-ch/youtube-music/pull/1627)
- chore(deps): update dependency electron to v29.0.0-alpha.8 [`#1608`](https://github.com/th-ch/youtube-music/pull/1608)
- fix(deps): update dependency @cliqz/adblocker-electron to v1.26.15 [`#1615`](https://github.com/th-ch/youtube-music/pull/1615)
- chore(deps): update dependency rollup to v4.9.4 [`#1591`](https://github.com/th-ch/youtube-music/pull/1591)
- fix(deps): update dependency @cliqz/adblocker-electron-preload to v1.26.15 [`#1616`](https://github.com/th-ch/youtube-music/pull/1616)
- chore(deps): update pnpm to v8.14.1 [`#1619`](https://github.com/th-ch/youtube-music/pull/1619)
- chore(deps): update dependency eslint-plugin-prettier to v5.1.3 [`#1618`](https://github.com/th-ch/youtube-music/pull/1618)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.18.1 [`#1612`](https://github.com/th-ch/youtube-music/pull/1612)
- fix(deps): update dependency youtubei.js to v8.2.0 [`#1614`](https://github.com/th-ch/youtube-music/pull/1614)
- chore(deps): update dependency electron-vite to v2.0.0 [`#1609`](https://github.com/th-ch/youtube-music/pull/1609)
- chore(deps): update dependency @typescript-eslint/eslint-plugin to v6.18.0 [`#1603`](https://github.com/th-ch/youtube-music/pull/1603)
- chore(deps): update dependency electron-vite to v2.0.0-beta.4 [`#1602`](https://github.com/th-ch/youtube-music/pull/1602)
- fix: fix upgrade button [`#1199`](https://github.com/th-ch/youtube-music/issues/1199)
- fix(mpris): fix mpris invalid position [`#1726`](https://github.com/th-ch/youtube-music/issues/1726)
- fix: discord RPC (fix #1664) [`#1664`](https://github.com/th-ch/youtube-music/issues/1664)
- fix: remove sign-in button (fix #1199) [`#1199`](https://github.com/th-ch/youtube-music/issues/1199)
- Fix #1617 [`#1617`](https://github.com/th-ch/youtube-music/issues/1617)
- fix(crossfade): fix #1633 [`#1633`](https://github.com/th-ch/youtube-music/issues/1633)
- fix: fix #1621 [`#1621`](https://github.com/th-ch/youtube-music/issues/1621)
- fix(tuna-obs): partially fix #1596 [`#1596`](https://github.com/th-ch/youtube-music/issues/1596)
- fix(discord): fix hide duration button [`#1644`](https://github.com/th-ch/youtube-music/issues/1644)
- fix(in-app-menu): fix invalid `margin-top` [`#1597`](https://github.com/th-ch/youtube-music/issues/1597)
- fix(README): fix `plugins` path [`#1598`](https://github.com/th-ch/youtube-music/issues/1598)
- chore(i18n): Translated using Weblate (Vietnamese) [`0528637`](https://github.com/th-ch/youtube-music/commit/05286371353e8b4c36a5b9fe9011ae5dfdc7ee82)
- chore: update pnpm-lock [`fd8d59b`](https://github.com/th-ch/youtube-music/commit/fd8d59bada56dab4e156d22394fe0c5efec5abc4)
- fix(in-app-menu): fix app crash in production [`febc63e`](https://github.com/th-ch/youtube-music/commit/febc63edef375bd82db48b7fb460ec5a601ab872)
#### [v3.2.2](https://github.com/th-ch/youtube-music/compare/v3.2.1...v3.2.2) #### [v3.2.2](https://github.com/th-ch/youtube-music/compare/v3.2.1...v3.2.2)
> 5 January 2024
- feat(tray): Add song info and paused icon [`#1592`](https://github.com/th-ch/youtube-music/pull/1592) - feat(tray): Add song info and paused icon [`#1592`](https://github.com/th-ch/youtube-music/pull/1592)
- fix(skip-silences): fix audio distorted [`#1141`](https://github.com/th-ch/youtube-music/issues/1141) - fix(skip-silences): fix audio distorted [`#1141`](https://github.com/th-ch/youtube-music/issues/1141)
- chore(deps): update dependency rollup to v4.9.3 [`0c3c380`](https://github.com/th-ch/youtube-music/commit/0c3c3805918adf2a185a7f1dc67ea3af8135863d) - chore(deps): update dependency rollup to v4.9.3 [`0c3c380`](https://github.com/th-ch/youtube-music/commit/0c3c3805918adf2a185a7f1dc67ea3af8135863d)

View File

@ -478,7 +478,7 @@
</a> </a>
</li> </li>
</ul> </ul>
<div class="footer-copyright">© 2021 th-ch</div> <div class="footer-copyright">© 2024 th-ch</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
{ {
"name": "youtube-music", "name": "youtube-music",
"productName": "YouTube Music", "productName": "YouTube Music",
"version": "3.2.2", "version": "3.3.2",
"description": "YouTube Music Desktop App - including custom plugins", "description": "YouTube Music Desktop App - including custom plugins",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"license": "MIT", "license": "MIT",
@ -160,13 +160,13 @@
"electron-localshortcut": "3.2.1", "electron-localshortcut": "3.2.1",
"electron-store": "8.1.0", "electron-store": "8.1.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-updater": "6.1.7", "electron-updater": "6.1.8",
"fast-average-color": "9.4.0", "fast-average-color": "9.4.0",
"fast-equals": "5.0.1", "fast-equals": "5.0.1",
"filenamify": "6.0.0", "filenamify": "6.0.0",
"howler": "2.2.4", "howler": "2.2.4",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"i18next": "23.8.2", "i18next": "23.9.0",
"keyboardevent-from-electron-accelerator": "2.0.0", "keyboardevent-from-electron-accelerator": "2.0.0",
"keyboardevents-areequal": "0.2.2", "keyboardevents-areequal": "0.2.2",
"node-html-parser": "6.1.12", "node-html-parser": "6.1.12",
@ -176,7 +176,7 @@
"serve": "14.2.1", "serve": "14.2.1",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9", "simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
"solid-floating-ui": "0.3.1", "solid-floating-ui": "0.3.1",
"solid-js": "1.8.14", "solid-js": "1.8.15",
"solid-styled-components": "0.28.5", "solid-styled-components": "0.28.5",
"solid-transition-group": "0.2.3", "solid-transition-group": "0.2.3",
"ts-morph": "21.0.1", "ts-morph": "21.0.1",
@ -192,17 +192,17 @@
"@types/howler": "2.2.11", "@types/howler": "2.2.11",
"@types/html-to-text": "9.0.4", "@types/html-to-text": "9.0.4",
"@types/semver": "7.5.7", "@types/semver": "7.5.7",
"@typescript-eslint/eslint-plugin": "7.0.1", "@typescript-eslint/eslint-plugin": "7.0.2",
"bufferutil": "4.0.8", "bufferutil": "4.0.8",
"builtin-modules": "3.3.0", "builtin-modules": "3.3.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"del-cli": "5.1.0", "del-cli": "5.1.0",
"discord-api-types": "0.37.69", "discord-api-types": "0.37.70",
"electron": "28.2.2", "electron": "28.2.3",
"electron-builder": "24.9.1", "electron-builder": "24.9.1",
"electron-devtools-installer": "3.2.0", "electron-devtools-installer": "3.2.0",
"electron-vite": "2.0.0", "electron-vite": "2.0.0",
"esbuild": "0.20.0", "esbuild": "0.20.1",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-import-resolver-exports": "1.0.0-beta.5", "eslint-import-resolver-exports": "1.0.0-beta.5",
"eslint-import-resolver-typescript": "3.6.1", "eslint-import-resolver-typescript": "3.6.1",
@ -211,13 +211,13 @@
"glob": "10.3.10", "glob": "10.3.10",
"node-gyp": "10.0.1", "node-gyp": "10.0.1",
"playwright": "1.41.2", "playwright": "1.41.2",
"rollup": "4.10.0", "rollup": "4.12.0",
"typescript": "5.3.3", "typescript": "5.3.3",
"utf-8-validate": "6.0.3", "utf-8-validate": "6.0.3",
"vite": "5.1.1", "vite": "5.1.3",
"vite-plugin-inspect": "0.8.3", "vite-plugin-inspect": "0.8.3",
"vite-plugin-resolve": "2.5.1", "vite-plugin-resolve": "2.5.1",
"vite-plugin-solid": "2.9.1", "vite-plugin-solid": "2.10.1",
"ws": "8.16.0" "ws": "8.16.0"
}, },
"auto-changelog": { "auto-changelog": {
@ -226,5 +226,5 @@
"unreleased": true, "unreleased": true,
"output": "changelog.md" "output": "changelog.md"
}, },
"packageManager": "pnpm@8.15.1" "packageManager": "pnpm@8.15.3"
} }

472
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,25 +16,44 @@ const migrations = {
secret?: string; secret?: string;
}; };
if (lastfmConfig) { if (lastfmConfig) {
const scrobblerConfig = store.get( let scrobblerConfig = store.get(
'plugins.scrobbler', 'plugins.scrobbler',
) as { ) as {
enabled?: boolean; enabled?: boolean;
scrobblers: { scrobblers?: {
lastfm: { lastfm?: {
enabled?: boolean; enabled?: boolean;
token?: string; token?: string;
session_key?: string; sessionKey?: string;
api_root?: string; apiRoot?: string;
api_key?: string; apiKey?: string;
secret?: string; secret?: string;
}; };
}; };
}; } | undefined;
scrobblerConfig.enabled = lastfmConfig.enabled; if (!scrobblerConfig) {
scrobblerConfig.scrobblers.lastfm = lastfmConfig; scrobblerConfig = {
enabled: lastfmConfig.enabled,
};
}
if (!scrobblerConfig.scrobblers) {
scrobblerConfig.scrobblers = {
lastfm: {},
};
}
scrobblerConfig.scrobblers.lastfm = {
enabled: lastfmConfig.enabled,
token: lastfmConfig.token,
sessionKey: lastfmConfig.session_key,
apiRoot: lastfmConfig.api_root,
apiKey: lastfmConfig.api_key,
secret: lastfmConfig.secret,
};
store.set('plugins.scrobbler', scrobblerConfig); store.set('plugins.scrobbler', scrobblerConfig);
store.delete('plugins.lastfm');
} }
}, },
'>=3.0.0'(store: Conf<Record<string, unknown>>) { '>=3.0.0'(store: Conf<Record<string, unknown>>) {

View File

@ -46,7 +46,7 @@
"hide-menu-enabled": { "hide-menu-enabled": {
"detail": "Das Menü ist versteckt, nutze 'Alt', um es zu aufzurufen (oder 'Escape' beim Verwenden des In-App-Menüs)", "detail": "Das Menü ist versteckt, nutze 'Alt', um es zu aufzurufen (oder 'Escape' beim Verwenden des In-App-Menüs)",
"message": "Menü verstecken ist aktiviert", "message": "Menü verstecken ist aktiviert",
"title": "Menü Verstecken Aktiviert" "title": "Menü verstecken aktiviert"
}, },
"need-to-restart": { "need-to-restart": {
"buttons": { "buttons": {
@ -55,7 +55,7 @@
}, },
"detail": "\"{{pluginName}}\"-Erweiterung erfordert einen Neustart, um in Kraft zu treten", "detail": "\"{{pluginName}}\"-Erweiterung erfordert einen Neustart, um in Kraft zu treten",
"message": "\"{{pluginName}}\" muss neugestartet werden", "message": "\"{{pluginName}}\" muss neugestartet werden",
"title": "Neustart Erforderlich" "title": "Neustart erforderlich"
}, },
"unresponsive": { "unresponsive": {
"buttons": { "buttons": {
@ -75,7 +75,7 @@
}, },
"detail": "Eine neue Version ist verfügbar und kann unter {{downloadLink}} heruntergeladen werden", "detail": "Eine neue Version ist verfügbar und kann unter {{downloadLink}} heruntergeladen werden",
"message": "Eine neue Version ist verfügbar", "message": "Eine neue Version ist verfügbar",
"title": "Aktualisierung Verfügbar" "title": "Aktualisierung verfügbar"
} }
}, },
"menu": { "menu": {
@ -87,7 +87,7 @@
"go-back": "Zurück gehen", "go-back": "Zurück gehen",
"go-forward": "Vorwärts gehen", "go-forward": "Vorwärts gehen",
"quit": "Beenden", "quit": "Beenden",
"restart": "Anwendung Neustarten" "restart": "Anwendung neustarten"
} }
}, },
"options": { "options": {
@ -124,7 +124,7 @@
"language": { "language": {
"dialog": { "dialog": {
"message": "Sprache wird nach Neustart geändert", "message": "Sprache wird nach Neustart geändert",
"title": "Sprache Geändert" "title": "Sprache geändert"
}, },
"label": "Sprache", "label": "Sprache",
"submenu": { "submenu": {
@ -212,6 +212,14 @@
}, },
"album-color-theme": { "album-color-theme": {
"description": "Wendet ein dynamisches Farbthema und visuelle Effekte auf Basis der Farbpalette des Albumcovers an", "description": "Wendet ein dynamisches Farbthema und visuelle Effekte auf Basis der Farbpalette des Albumcovers an",
"menu": {
"color-mix-ratio": {
"label": "Farbmischungsverhältnis",
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Thema aus Albumfarbe" "name": "Thema aus Albumfarbe"
}, },
"ambient-mode": { "ambient-mode": {
@ -230,7 +238,7 @@
} }
}, },
"opacity": { "opacity": {
"label": "Durchsichtigkeit", "label": "Transparenz",
"submenu": { "submenu": {
"percent": "{{opacity}}%" "percent": "{{opacity}}%"
} }
@ -275,7 +283,7 @@
"description": "Untertitelwähler für YouTube Music-Audio-Lieder", "description": "Untertitelwähler für YouTube Music-Audio-Lieder",
"menu": { "menu": {
"autoload": "Wähle automatisch den zuletzt verwendeten Untertitel", "autoload": "Wähle automatisch den zuletzt verwendeten Untertitel",
"disable-captions": "Standartmäßig keine Untertitel" "disable-captions": "Standardmäßig keine Untertitel"
}, },
"name": "Untertitelwähler", "name": "Untertitelwähler",
"prompt": { "prompt": {
@ -577,23 +585,25 @@
}, },
"listenbrainz": { "listenbrainz": {
"token": "ListenBrainz-Benutzer-Token eintragen" "token": "ListenBrainz-Benutzer-Token eintragen"
} },
"scrobble-other-media": "Andere Medien scrobbeln"
}, },
"name": "Scrobbler", "name": "Scrobbler",
"prompt": { "prompt": {
"lastfm": { "lastfm": {
"api-key": "Last.fm API-Schlüssel", "api-key": "Last.fm API-Schlüssel",
"api-secret": "Last.fm API secret" "api-secret": "Last.fm API-Kennwort"
}, },
"listenbrainz": { "listenbrainz": {
"token": { "token": {
"label": "ListenBrainz-Benutzer-Token eintragen" "label": "ListenBrainz-Benutzer-Token eintragen:",
"title": "ListenBrainz-Token"
} }
} }
} }
}, },
"shortcuts": { "shortcuts": {
"description": "Ermöglicht das Festlegen globaler Hotkeys für die Wiedergabe (Abspielen/Pause/Nächster/Vorheriger) + Deaktivieren des Medien-OSD durch Überschreiben der Medientasten + Aktivieren von Strg/CMD + F zum Suchen + Aktivieren der Linux mpris-Unterstützung für Medientasten + Angepasste Tastenkürzel für fortgeschrittene Benutzer.", "description": "Ermöglicht das Festlegen globaler Hotkeys für die Wiedergabe (Abspielen/Pause/Nächster/Vorheriger) + Deaktivieren des Medien-OSD durch Überschreiben der Medientasten + Aktivieren von Strg/CMD + F zum Suchen + Aktivieren der Linux mpris-Unterstützung für Medientasten + Angepasste Tastenkürzel für fortgeschrittene Benutzer",
"menu": { "menu": {
"override-media-keys": "Medientasten überschreiben", "override-media-keys": "Medientasten überschreiben",
"set-keybinds": "Globale Liedsteuerung setzen" "set-keybinds": "Globale Liedsteuerung setzen"

View File

@ -579,6 +579,14 @@
}, },
"scrobbler": { "scrobbler": {
"description": "Add scrobbling support (etc. last.fm, Listenbrainz)", "description": "Add scrobbling support (etc. last.fm, Listenbrainz)",
"dialog": {
"lastfm": {
"auth-failed": {
"title": "Authentication Failed",
"message": "Failed to authenticate with Last.fm\nHide the popup until the next restart."
}
}
},
"menu": { "menu": {
"scrobble-other-media": "Scrobble other media", "scrobble-other-media": "Scrobble other media",
"lastfm": { "lastfm": {

View File

@ -98,7 +98,7 @@
"submenu": { "submenu": {
"auto-reset-app-cache": "Mengatur ulang cache aplikasi saat aplikasi dimulai", "auto-reset-app-cache": "Mengatur ulang cache aplikasi saat aplikasi dimulai",
"disable-hardware-acceleration": "Menonaktifkan akselerasi perangkat keras", "disable-hardware-acceleration": "Menonaktifkan akselerasi perangkat keras",
"edit-config-json": "Edit config.json", "edit-config-json": "Ubah config.json",
"override-user-agent": "Mengesampingkan User-Agent", "override-user-agent": "Mengesampingkan User-Agent",
"restart-on-config-changes": "Mulai ulang pada perubahan konfigurasi", "restart-on-config-changes": "Mulai ulang pada perubahan konfigurasi",
"set-proxy": { "set-proxy": {
@ -194,7 +194,7 @@
"show": "Tampilkan jendela", "show": "Tampilkan jendela",
"tooltip": { "tooltip": {
"default": "YouTube Musik", "default": "YouTube Musik",
"with-song-info": "YouTube Music: {{artist}} - {{Judul}}" "with-song-info": "YouTube Music: {{artist}} - {{title}}"
} }
} }
}, },
@ -207,7 +207,7 @@
"name": "Pemblokir Iklan" "name": "Pemblokir Iklan"
}, },
"album-actions": { "album-actions": {
"description": "Menambahkan tombol Tidak Suka, Tidak Suka, Suka, dan Tidak Suka untuk menerapkannya ke semua lagu dalam daftar putar atau album", "description": "Tambah tombol Suka, Batal Suka, Tidak Suka dan Batal Tidak Suka untuk diterapkan ke semua lagu dalam daftar putar atau album",
"name": "Tindakan Album" "name": "Tindakan Album"
}, },
"album-color-theme": { "album-color-theme": {
@ -398,7 +398,285 @@
"video-id-not-found": "Video tidak ditemukan", "video-id-not-found": "Video tidak ditemukan",
"writing-id3": "Menulis tanda ID3…" "writing-id3": "Menulis tanda ID3…"
} }
},
"description": "Unduh MP3 / sumber suara secara langsung via antarmuka",
"menu": {
"choose-download-folder": "Pilih folder unduhan",
"download-playlist": "Unduh daftar putar",
"presets": "Prasetel",
"skip-existing": "Lewati berkas yang sudah ada"
},
"name": "Pengunduh",
"renderer": {
"can-not-update-progress": "Tidak dapat memperbarui proses"
},
"templates": {
"button": "Unduh"
} }
},
"exponential-volume": {
"description": "Buat penggeser volume menjadi eksponen sehingga memudahkan memilih volume yang lebih rendah.",
"name": "Volume Eksponen"
},
"in-app-menu": {
"description": "Buat bilah-menu terlihat indah, gelap atau serupa dengan album",
"menu": {
"hide-dom-window-controls": "Sembunyikan DOM pengendali jendela"
},
"name": "Menu di Aplikasi"
},
"lumiastream": {
"description": "Tambah dukungan Lumia Stream",
"name": "Lumia Stream [Beta]"
},
"lyrics-genius": {
"description": "Tambah dukungan lirik untuk kebanyakan lagu",
"menu": {
"romanized-lyrics": "Romanisasi Lirik"
},
"name": "Lirik Genius",
"renderer": {
"fetched-lyrics": "Lirik yang diambil untuk Genius"
}
},
"music-together": {
"description": "Bagikan daftar putar dengan yang lain. Saat host memainkan lagu, semua orang akan mendengarkan lagu yang sama",
"dialog": {
"enter-host": "Masukkan ID Host"
},
"internal": {
"save": "Simpan",
"track-source": "Sumber Trek",
"unknown-user": "Pengguna Tidak Diketahui"
},
"menu": {
"click-to-copy-id": "Salin ID Host",
"close": "Tutup Musik Bersama",
"connected-users": "Pengguna Terhubung",
"disconnect": "Putuskan Musik Bersama",
"empty-user": "Tidak ada pengguna terhubung",
"host": "Host Musik Bersama",
"join": "Gabung Musik Bersama",
"permission": {
"all": "Izinkan tamu untuk mengendalikan daftar putar dan pemutar",
"host-only": "Hanya host yang dapat mengendalikan daftar putar dan pemutar",
"playlist": "Izinkan tamu untuk mengendalikan daftar putar"
},
"set-permission": "Ubah Pengendali Izin",
"status": {
"disconnected": "Terputus",
"guest": "Terhubung sebagai Tamu",
"host": "Terhubung sebagai Host"
}
},
"name": "Musik Bersama [Beta]",
"toast": {
"add-song-failed": "Gagal untuk menambahkan lagu",
"closed": "Musik Bersama ditutup",
"disconnected": "Musik Bersama terputus",
"host-failed": "Gagal untuk memulai Musik Bersama",
"id-copied": "ID Host tersalin ke papan klip",
"id-copy-failed": "Gagal menyalin ID Host ke papan klip",
"join-failed": "Gagal untuk bergabung ke Musik Bersama",
"joined": "Bergabung ke Musik Bersama",
"permission-changed": "Perizinan Musik Bersama diubah ke \"{{permission}}\"",
"remove-song-failed": "Gagal menghapus lagu",
"user-connected": "{{name}} bergabung ke Musik Bersama",
"user-disconnected": "{{name}} meninggalkan Musik Bersama"
}
},
"navigation": {
"description": "panah navigasi Selanjutnya/Sebelumnya terintegrasi pada antarmuka, layaknya peramban kesukaan Anda",
"name": "Navigasi"
},
"no-google-login": {
"description": "Hapus tombol dan tautan masuk Google dari antarmuka",
"name": "Tanpa Google Login"
},
"notifications": {
"description": "Tampilkan pemberitahuan saat lagu dimainkan (pemberitahuan interaktif tersedia di Windows)",
"menu": {
"interactive": "Pemberitahuan Interaktif",
"interactive-settings": {
"label": "Pengaturan Interaktif",
"submenu": {
"hide-button-text": "Sembunyikan teks tombol",
"refresh-on-play-pause": "Segarkan saat Putar/Jeda",
"tray-controls": "Buka/Tutup saat baki ditekan"
}
},
"priority": "Prioritas Pemberitahuan",
"toast-style": "Gaya Toast",
"unpause-notification": "Tampilkan pemberitahuan saat tidak dijeda"
},
"name": "Pemberitahuan"
},
"picture-in-picture": {
"description": "Izinkan untuk memindahkan aplikasi ke mode gambar-dalam-gambar",
"menu": {
"always-on-top": "Selalu di atas",
"hotkey": {
"label": "Pintasan",
"prompt": {
"keybind-options": {
"hotkey": "Pintasan"
},
"label": "Pilih pintasan untuk beralih ke gambar-dalam-gambar",
"title": "Pintasan gambar-dalam-gambar"
}
},
"save-window-position": "Simpan posisi jendela",
"save-window-size": "Simpan ukuran jendela",
"use-native-pip": "Gunakan PiP bawaan peramban"
},
"name": "Gambar-dalam-gambar",
"templates": {
"button": "Gambar-dalam-gambar"
}
},
"playback-speed": {
"description": "Dengarkan cepat, dengarkan perlahan! Tambahkan penggeser untuk mengendalikan kecepatan lagu",
"name": "Kecepatan Pemutar",
"templates": {
"button": "Kecepatan"
}
},
"precise-volume": {
"description": "Kendalikan volume secara presisi menggunakan roda tetikus/pintasan, dengan HUD kustom dan langkah volume yang dapat diatur",
"menu": {
"arrows-shortcuts": "Kendali Tombol Panah Lokal",
"custom-volume-steps": "Atur Langkah Volume Kustom",
"global-shortcuts": "Pintasan Global"
},
"name": "Volume Presisi",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Kurangi Volume",
"increase": "Tingkatkan Volume"
},
"label": "Pilih Pintasan Volume Global:",
"title": "Pintasan Volume Global"
},
"volume-steps": {
"label": "Pilih Langkah Peningkatan/Pengurangan Volume",
"title": "Langkah Volume"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Kualitas Terkini: {{quality}}",
"message": "Pilih Kualitas Video:",
"title": "Pilih Kualitas Video"
}
}
},
"description": "Izinkan untuk mengubah kualitas video dengan tombol pada hamparan video",
"name": "Pengubah Kualitas Video"
},
"scrobbler": {
"description": "Tambahkan dukungan scrobbling (mis. last.fm, Listenbrainz)",
"menu": {
"lastfm": {
"api-settings": "Pengaturan API Last.fm"
},
"listenbrainz": {
"token": "Masukkan token pengguna ListenBrainz"
},
"scrobble-other-media": "Scrobble media lain"
},
"name": "Scrobbler",
"prompt": {
"lastfm": {
"api-key": "Kunci API Last.fm",
"api-secret": "Secret API Last.fm"
},
"listenbrainz": {
"token": {
"label": "Masukkan token pengguna ListenBrainz Anda:",
"title": "Token ListenBrainz"
}
}
}
},
"shortcuts": {
"description": "Izinkan pengaturan pintasan global untuk pemutar (main/jeda/selanjutnya/sebelumnya) dan mematikan OSD media dengan mengesampingkan tombol media, mengaktifkan Ctrl/CMD + F untuk pencarian, mengaktifkan dukungan MPRIS Linux untuk tombol media, dan tombol pintasan kustom untuk pengguna lanjutan",
"menu": {
"override-media-keys": "Timpa Tombol Media",
"set-keybinds": "Atur Pengendali Lagu Global"
},
"name": "Pintasan (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Selanjutnya",
"play-pause": "Main / Jeda",
"previous": "Sebelumnya"
},
"label": "Pilih Pintasan Global untuk Pengendali Lagu:",
"title": "Pintasan Global"
}
}
},
"skip-disliked-songs": {
"description": "Lewati lagu yang tidak disukai",
"name": "Lewati Lagu yang Tidak Disukai"
},
"skip-silences": {
"description": "Otomatis lewati bagian hening dari lagu",
"name": "Lewati Keheningan"
},
"sponsorblock": {
"description": "Otomatis Melewati bagian yang bukan musik seperti intro/outro atau bagian dari video musik di mana lagu tidak dimainkan",
"name": "SponsorBlock"
},
"taskbar-mediacontrol": {
"description": "Kendalikan pemutaran dari bilah alat Windows",
"name": "Pengendali Media di Bilah Alat"
},
"touchbar": {
"description": "Tambahkan widget TouchBar untuk pengguna macOS",
"name": "TouchBar"
},
"tuna-obs": {
"description": "Integrasi dengan plugin Tuna OBS",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "Tambahkan tombol untuk beralih antara mode Lagu/Video. secara opsional juga dapat menghapus keseluruhan tab video",
"menu": {
"align": {
"label": "Perataan",
"submenu": {
"left": "Kiri",
"middle": "Tengah",
"right": "Kanan"
}
},
"force-hide": "Paksa hapus tab video",
"mode": {
"label": "Mode",
"submenu": {
"custom": "Peralih kustom",
"disabled": "Mati",
"native": "Peralih bawaan"
}
}
},
"name": "Peralih Video",
"templates": {
"button": "Lagu"
}
},
"visualizer": {
"description": "Tambahkan visualisator ke pemutar",
"menu": {
"visualizer-type": "Tipe Visualisator"
},
"name": "Visualisator"
} }
} }
} }

View File

@ -212,6 +212,14 @@
}, },
"album-color-theme": { "album-color-theme": {
"description": "Applica un tema dinamico e degli effetti visivi basandosi sul colore dell'album", "description": "Applica un tema dinamico e degli effetti visivi basandosi sul colore dell'album",
"menu": {
"color-mix-ratio": {
"label": "Percentiuale colore",
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Tema abbinato a colore album" "name": "Tema abbinato a colore album"
}, },
"ambient-mode": { "ambient-mode": {
@ -577,7 +585,8 @@
}, },
"listenbrainz": { "listenbrainz": {
"token": "Inserire il token utente per ListenBrainz" "token": "Inserire il token utente per ListenBrainz"
} },
"scrobble-other-media": "Scrobble altri media"
}, },
"name": "Scrobbler", "name": "Scrobbler",
"prompt": { "prompt": {

View File

@ -579,6 +579,14 @@
}, },
"scrobbler": { "scrobbler": {
"description": "스크로블링 지원을 추가합니다 (예: last.fm, Listenbrainz)", "description": "스크로블링 지원을 추가합니다 (예: last.fm, Listenbrainz)",
"dialog": {
"lastfm": {
"auth-failed": {
"message": "Last.fm 인증에 실패했습니다\n다음에 다시 시작할 때까지 팝업을 숨깁니다.",
"title": "인증 실패"
}
}
},
"menu": { "menu": {
"lastfm": { "lastfm": {
"api-settings": "Last.fm API 설정" "api-settings": "Last.fm API 설정"

View File

@ -212,6 +212,13 @@
}, },
"album-color-theme": { "album-color-theme": {
"description": "Stosuje dynamiczny motyw i efekty wizualne w oparciu o paletę kolorów albumu", "description": "Stosuje dynamiczny motyw i efekty wizualne w oparciu o paletę kolorów albumu",
"menu": {
"color-mix-ratio": {
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Motyw kolorów albumu" "name": "Motyw kolorów albumu"
}, },
"ambient-mode": { "ambient-mode": {
@ -569,6 +576,24 @@
"description": "Umożliwia zmianę jakości wideo za pomocą przycisku na nakładce wideo", "description": "Umożliwia zmianę jakości wideo za pomocą przycisku na nakładce wideo",
"name": "Zmieniacz jakości wideo" "name": "Zmieniacz jakości wideo"
}, },
"scrobbler": {
"menu": {
"listenbrainz": {
"token": "Podaj token użytkownika ListenBrainz"
}
},
"prompt": {
"lastfm": {
"api-key": "klucz API Last.fm"
},
"listenbrainz": {
"token": {
"label": "Podaj swój token użytkownika ListenBrainz:",
"title": "Token ListenBrainz"
}
}
}
},
"shortcuts": { "shortcuts": {
"description": "Umożliwia ustawienie globalnych skrótów klawiszowych do odtwarzania (odtwarzanie/pauza/następny/poprzedni) + wyłączanie OSD multimediów poprzez zastąpienie klawiszy multimediów, włączając kombinację klawiszy Ctrl/CMD + F w celu wyszukiwania, obsługę Linux MPRIS dla klawiszy multimediów oraz niestandardowe skróty klawiszowe dla zaawansowanych użytkowników", "description": "Umożliwia ustawienie globalnych skrótów klawiszowych do odtwarzania (odtwarzanie/pauza/następny/poprzedni) + wyłączanie OSD multimediów poprzez zastąpienie klawiszy multimediów, włączając kombinację klawiszy Ctrl/CMD + F w celu wyszukiwania, obsługę Linux MPRIS dla klawiszy multimediów oraz niestandardowe skróty klawiszowe dla zaawansowanych użytkowników",
"menu": { "menu": {

View File

@ -53,6 +53,8 @@ import {
import { LoggerPrefix } from '@/utils'; import { LoggerPrefix } from '@/utils';
import { loadI18n, setLanguage, t } from '@/i18n'; import { loadI18n, setLanguage, t } from '@/i18n';
import ErrorHtmlAsset from '@assets/error.html?asset';
import type { PluginConfig } from '@/types/plugins'; import type { PluginConfig } from '@/types/plugins';
if (!is.macOS()) { if (!is.macOS()) {
@ -505,7 +507,7 @@ app.once('browser-window-created', (_event, win) => {
if (errorCode !== -3) { if (errorCode !== -3) {
// -3 is a false positive // -3 is a false positive
win.webContents.send('log', log); win.webContents.send('log', log);
win.webContents.loadFile(path.join(__dirname, 'error.html')); win.webContents.loadFile(ErrorHtmlAsset);
} }
}, },
); );
@ -586,7 +588,7 @@ app.whenReady().then(async () => {
); );
try { try {
// Check if shortcut is registered and valid // Check if shortcut is registered and valid
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if it doesn't exist yet
if ( if (
shortcutDetails.target !== appLocation || shortcutDetails.target !== appLocation ||
shortcutDetails.appUserModelId !== appID shortcutDetails.appUserModelId !== appID
@ -671,7 +673,9 @@ app.whenReady().then(async () => {
); );
} }
handleProtocol(command); const splited = decodeURIComponent(command).split(' ');
handleProtocol(splited.shift()!, splited);
return; return;
} }

View File

@ -30,7 +30,7 @@ import {
import { fetchFromGenius } from '@/plugins/lyrics-genius/main'; import { fetchFromGenius } from '@/plugins/lyrics-genius/main';
import { isEnabled } from '@/config/plugins'; import { isEnabled } from '@/config/plugins';
import { cleanupName, getImage, SongInfo } from '@/providers/song-info'; import { cleanupName, getImage, MediaType, type SongInfo } from '@/providers/song-info';
import { getNetFetchAsFetch } from '@/plugins/utils/main'; import { getNetFetchAsFetch } from '@/plugins/utils/main';
import { cache } from '@/providers/decorators'; import { cache } from '@/providers/decorators';
@ -686,6 +686,7 @@ const getMetadata = (info: TrackInfo): CustomSongInfo => ({
?.url, ?.url,
views: info.basic_info.view_count!, views: info.basic_info.view_count!,
songDuration: info.basic_info.duration!, songDuration: info.basic_info.duration!,
mediaType: MediaType.Audio,
}); });
// This is used to bypass age restrictions // This is used to bypass age restrictions

View File

@ -4,8 +4,24 @@ export interface InAppMenuConfig {
} }
export const defaultInAppMenuConfig: InAppMenuConfig = { export const defaultInAppMenuConfig: InAppMenuConfig = {
enabled: enabled:
(typeof window !== 'undefined' && (
!window.navigator?.userAgent?.includes('mac')) || (
(typeof global !== 'undefined' && global.process?.platform !== 'darwin'), typeof window !== 'undefined' &&
!window.navigator?.userAgent?.toLowerCase().includes('mac')
) ||
(
typeof global !== 'undefined' &&
global.process?.platform !== 'darwin'
)
) && (
(
typeof window !== 'undefined' &&
!window.navigator?.userAgent?.toLowerCase().includes('linux')
) ||
(
typeof global !== 'undefined' &&
global.process?.platform !== 'linux'
)
),
hideDOMWindowControls: false, hideDOMWindowControls: false,
}; };

View File

@ -2,7 +2,7 @@ import is from 'electron-is';
import { t } from '@/i18n'; import { t } from '@/i18n';
import type { InAppMenuConfig } from './index'; import type { InAppMenuConfig } from './constants';
import type { MenuContext } from '@/types/contexts'; import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu'; import type { MenuTemplate } from '@/menu';

View File

@ -1,5 +1,6 @@
import { JSX, splitProps } from 'solid-js'; import { JSX, splitProps } from 'solid-js';
import { css } from 'solid-styled-components'; import { css } from 'solid-styled-components';
import { cache } from '@/providers/decorators'; import { cache } from '@/providers/decorators';
const menuStyle = cache(() => css` const menuStyle = cache(() => css`

View File

@ -4,6 +4,7 @@ import { css } from 'solid-styled-components';
import { Transition } from 'solid-transition-group'; import { Transition } from 'solid-transition-group';
import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom'; import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui'; import { useFloating } from 'solid-floating-ui';
import { cache } from '@/providers/decorators'; import { cache } from '@/providers/decorators';
const panelStyle = cache(() => css` const panelStyle = cache(() => css`
@ -131,6 +132,7 @@ export const Panel = (props: PanelProps) => {
<Show when={local.open}> <Show when={local.open}>
<ul <ul
{...leftProps} {...leftProps}
data-ytmd-sub-panel={true}
ref={setPanel} ref={setPanel}
class={panelStyle()} class={panelStyle()}
style={{ style={{

View File

@ -9,9 +9,10 @@ import { PanelItem } from './PanelItem';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { WindowController } from './WindowController'; import { WindowController } from './WindowController';
import { cache } from '@/providers/decorators';
import type { RendererContext } from '@/types/contexts'; import type { RendererContext } from '@/types/contexts';
import type { InAppMenuConfig } from '../constants'; import type { InAppMenuConfig } from '../constants';
import { cache } from '@/providers/decorators';
const titleStyle = cache(() => css` const titleStyle = cache(() => css`
-webkit-app-region: drag; -webkit-app-region: drag;
@ -243,6 +244,19 @@ export const TitleBar = (props: TitleBarProps) => {
props.ipc.on('window-maximize', refetchMaximize); props.ipc.on('window-maximize', refetchMaximize);
props.ipc.on('window-unmaximize', refetchMaximize); props.ipc.on('window-unmaximize', refetchMaximize);
// close menu when the outside of the panel or sub-panel is clicked
document.body.addEventListener('click', (e) => {
if (
e.target instanceof HTMLElement &&
!(
e.target.closest('nav[data-ytmd-main-panel]') ||
e.target.closest('ul[data-ytmd-sub-panel]')
)
) {
setOpenTarget(null);
}
});
}); });
createEffect(() => { createEffect(() => {
@ -252,7 +266,7 @@ export const TitleBar = (props: TitleBarProps) => {
}); });
return ( return (
<nav class={titleStyle()} data-macos={props.isMacOS}> <nav data-ytmd-main-panel={true} class={titleStyle()} data-macos={props.isMacOS}>
<IconButton <IconButton
onClick={() => setCollapsed(!collapsed())} onClick={() => setCollapsed(!collapsed())}
style={{ style={{

View File

@ -38,7 +38,7 @@ export const fetchFromGenius = async (metadata: SongInfo) => {
const songArtist = `${cleanupName(metadata.artist)}`; const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics: string | null; let lyrics: string | null;
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal /* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise, normal
Genius Lyrics behavior is observed. Genius Lyrics behavior is observed.
*/ */
let hasAsianChars = false; let hasAsianChars = false;

View File

@ -74,7 +74,7 @@ export class Connection {
return conn; return conn;
} }
async disconnect() { disconnect() {
if (this._mode === 'disconnected') throw new Error('Already disconnected'); if (this._mode === 'disconnected') throw new Error('Already disconnected');
this._mode = 'disconnected'; this._mode = 'disconnected';

View File

@ -6,9 +6,9 @@ import { t } from '@/i18n';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options'; import promptOptions from '@/providers/prompt-options';
import { AppAPI, getDefaultProfile, Permission, Profile, VideoData } from './types'; import { getDefaultProfile, type Permission, type Profile, type VideoData } from './types';
import { Queue } from './queue'; import { Queue } from './queue';
import { Connection, ConnectionEventUnion } from './connection'; import { Connection, type ConnectionEventUnion } from './connection';
import { createHostPopup } from './ui/host'; import { createHostPopup } from './ui/host';
import { createGuestPopup } from './ui/guest'; import { createGuestPopup } from './ui/guest';
import { createSettingPopup } from './ui/setting'; import { createSettingPopup } from './ui/setting';
@ -19,6 +19,7 @@ import style from './style.css?inline';
import type { YoutubePlayer } from '@/types/youtube-player'; import type { YoutubePlayer } from '@/types/youtube-player';
import type { RendererContext } from '@/types/contexts'; import type { RendererContext } from '@/types/contexts';
import type { VideoDataChanged } from '@/types/video-data-changed'; import type { VideoDataChanged } from '@/types/video-data-changed';
import type { AppElement } from '@/types/queue';
type RawAccountData = { type RawAccountData = {
accountName: { accountName: {
@ -41,7 +42,7 @@ export default createPlugin<
{ {
connection?: Connection; connection?: Connection;
ipc?: RendererContext<never>['ipc']; ipc?: RendererContext<never>['ipc'];
api: HTMLElement & AppAPI | null; api: AppElement | null;
queue?: Queue; queue?: Queue;
playerApi?: YoutubePlayer; playerApi?: YoutubePlayer;
showPrompt: (title: string, label: string) => Promise<string>; showPrompt: (title: string, label: string) => Promise<string>;
@ -557,7 +558,7 @@ export default createPlugin<
start({ ipc }) { start({ ipc }) {
this.ipc = ipc; this.ipc = ipc;
this.showPrompt = async (title: string, label: string) => ipc.invoke('music-together:prompt', title, label) as Promise<string>; this.showPrompt = async (title: string, label: string) => ipc.invoke('music-together:prompt', title, label) as Promise<string>;
this.api = document.querySelector<HTMLElement & AppAPI>('ytmusic-app'); this.api = document.querySelector<AppElement>('ytmusic-app');
/* setup */ /* setup */
document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML); document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML);

View File

@ -1,11 +1,12 @@
import { getMusicQueueRenderer } from './song'; import { getMusicQueueRenderer } from './song';
import { mapQueueItem } from './utils'; import { mapQueueItem } from './utils';
import { ConnectionEventUnion } from '@/plugins/music-together/connection';
import { t } from '@/i18n'; import { t } from '@/i18n';
import type { Profile, QueueAPI, VideoData } from '../types'; import type { ConnectionEventUnion } from '@/plugins/music-together/connection';
import type { Profile, VideoData } from '../types';
import type { QueueItem } from '@/types/datahost-get-state'; import type { QueueItem } from '@/types/datahost-get-state';
import type { QueueElement } from '@/types/queue';
const getHeaderPayload = (() => { const getHeaderPayload = (() => {
let payload: { let payload: {
@ -103,26 +104,29 @@ const getHeaderPayload = (() => {
export type QueueOptions = { export type QueueOptions = {
videoList?: VideoData[]; videoList?: VideoData[];
owner?: Profile; owner?: Profile;
queue?: HTMLElement & QueueAPI; queue?: QueueElement;
getProfile: (id: string) => Profile | undefined; getProfile: (id: string) => Profile | undefined;
} }
export type QueueEventListener = (event: ConnectionEventUnion) => void; export type QueueEventListener = (event: ConnectionEventUnion) => void;
export class Queue { export class Queue {
private queue: (HTMLElement & QueueAPI); private readonly queue: QueueElement;
private originalDispatch?: (obj: { private originalDispatch?: (obj: {
type: string; type: string;
payload?: { items?: QueueItem[] | undefined; }; payload?: { items?: QueueItem[] | undefined; };
}) => void; }) => void;
private internalDispatch = false; private internalDispatch = false;
private ignoreFlag = false; private ignoreFlag = false;
private listeners: QueueEventListener[] = []; private listeners: QueueEventListener[] = [];
private owner: Profile | null = null;
private getProfile: (id: string) => Profile | undefined; private owner: Profile | null;
private readonly getProfile: (id: string) => Profile | undefined;
constructor(options: QueueOptions) { constructor(options: QueueOptions) {
this.getProfile = options.getProfile; this.getProfile = options.getProfile;
this.queue = options.queue ?? document.querySelector<HTMLElement & QueueAPI>('#queue')!; this.queue = options.queue ?? (document.querySelector<QueueElement>('#queue')!);
this.owner = options.owner ?? null; this.owner = options.owner ?? null;
this._videoList = options.videoList ?? []; this._videoList = options.videoList ?? [];
} }
@ -135,11 +139,11 @@ export class Queue {
} }
get selectedIndex() { get selectedIndex() {
return mapQueueItem((it) => it?.selected, this.queue.store.getState().queue.items).findIndex(Boolean) ?? 0; return mapQueueItem((it) => it?.selected, this.queue.queue.store.store.getState().queue.items).findIndex(Boolean) ?? 0;
} }
get rawItems() { get rawItems() {
return this.queue?.store.getState().queue.items; return this.queue?.queue.store.store.getState().queue.items;
} }
get flatItems() { get flatItems() {
@ -169,8 +173,8 @@ export class Queue {
this.queue?.dispatch({ this.queue?.dispatch({
type: 'ADD_ITEMS', type: 'ADD_ITEMS',
payload: { payload: {
nextQueueItemId: this.queue.store.getState().queue.nextQueueItemId, nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId,
index: index ?? this.queue.store.getState().queue.items.length ?? 0, index: index ?? this.queue.queue.store.store.getState().queue.items.length ?? 0,
items, items,
shuffleEnabled: false, shuffleEnabled: false,
shouldAssignIds: true shouldAssignIds: true
@ -249,7 +253,7 @@ export class Queue {
return; return;
} }
if (this.originalDispatch) this.queue.store.dispatch = this.originalDispatch; if (this.originalDispatch) this.queue.queue.store.store.dispatch = this.originalDispatch;
} }
injection() { injection() {
@ -258,8 +262,8 @@ export class Queue {
return; return;
} }
this.originalDispatch = this.queue.store.dispatch; this.originalDispatch = this.queue.queue.store.store.dispatch;
this.queue.store.dispatch = (event) => { this.queue.queue.store.store.dispatch = (event) => {
if (!this.queue || !this.owner) { if (!this.queue || !this.owner) {
console.error('Queue is not initialized!'); console.error('Queue is not initialized!');
return; return;
@ -361,10 +365,13 @@ export class Queue {
const fakeContext = { const fakeContext = {
...this.queue, ...this.queue,
store: { queue: {
...this.queue.store, ...this.queue.queue,
dispatch: this.originalDispatch store: {
} ...this.queue.queue.store,
dispatch: this.originalDispatch,
}
},
}; };
this.originalDispatch?.call(fakeContext, event); this.originalDispatch?.call(fakeContext, event);
}; };
@ -400,7 +407,7 @@ export class Queue {
type: 'UPDATE_ITEMS', type: 'UPDATE_ITEMS',
payload: { payload: {
items: items, items: items,
nextQueueItemId: this.queue.store.getState().queue.nextQueueItemId, nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId,
shouldAssignIds: true, shouldAssignIds: true,
currentIndex: -1 currentIndex: -1
} }

View File

@ -1,37 +1,3 @@
import { YoutubePlayer } from '@/types/youtube-player';
import { GetState, QueueItem } from '@/types/datahost-get-state';
type StoreState = GetState;
type Store = {
dispatch: (obj: {
type: string;
payload?: {
items?: QueueItem[];
};
}) => void;
getState: () => StoreState;
replaceReducer: (param1: unknown) => unknown;
subscribe: (callback: () => void) => unknown;
}
export type QueueAPI = {
dispatch(obj: {
type: string;
payload?: unknown;
}): void;
getItems(): unknown[];
store: Store;
continuation?: string;
autoPlaying?: boolean;
};
export type AppAPI = {
queue_: QueueAPI;
playerApi_: YoutubePlayer;
openToast: (message: string) => void;
// TODO: Add more
};
export type Profile = { export type Profile = {
id: string; id: string;
handleId: string; handleId: string;

View File

@ -307,9 +307,9 @@ export default (
savedNotification?.close(); savedNotification?.close();
}); });
changeProtocolHandler((cmd) => { changeProtocolHandler((cmd, args) => {
if (Object.keys(songControls).includes(cmd)) { if (Object.keys(songControls).includes(cmd)) {
songControls[cmd as keyof typeof songControls](); songControls[cmd as keyof typeof songControls](args as never);
if ( if (
config().refreshOnPlayPause && config().refreshOnPlayPause &&
(cmd === 'pause' || (cmd === 'play' && !config().unpauseNotification)) (cmd === 'pause' || (cmd === 'play' && !config().unpauseNotification))

View File

@ -11,7 +11,7 @@ export interface ScrobblerPluginConfig {
* *
* @default true * @default true
*/ */
scrobble_other_media: boolean, scrobbleOtherMedia: boolean,
scrobblers: { scrobblers: {
lastfm: { lastfm: {
/** /**
@ -27,19 +27,19 @@ export interface ScrobblerPluginConfig {
/** /**
* Session key used for scrobbling * Session key used for scrobbling
*/ */
session_key: string | undefined, sessionKey: string | undefined,
/** /**
* Root of the Last.fm API * Root of the Last.fm API
* *
* @default 'http://ws.audioscrobbler.com/2.0/' * @default 'http://ws.audioscrobbler.com/2.0/'
*/ */
api_root: string, apiRoot: string,
/** /**
* Last.fm api key registered by @semvis123 * Last.fm api key registered by @semvis123
* *
* @default '04d76faaac8726e60988e14c105d421a' * @default '04d76faaac8726e60988e14c105d421a'
*/ */
api_key: string, apiKey: string,
/** /**
* Last.fm api secret registered by @semvis123 * Last.fm api secret registered by @semvis123
* *
@ -63,27 +63,27 @@ export interface ScrobblerPluginConfig {
* *
* @default 'https://api.listenbrainz.org/1/' * @default 'https://api.listenbrainz.org/1/'
*/ */
api_root: string, apiRoot: string,
}, },
} }
} }
export const defaultConfig: ScrobblerPluginConfig = { export const defaultConfig: ScrobblerPluginConfig = {
enabled: false, enabled: false,
scrobble_other_media: true, scrobbleOtherMedia: true,
scrobblers: { scrobblers: {
lastfm: { lastfm: {
enabled: false, enabled: false,
token: undefined, token: undefined,
session_key: undefined, sessionKey: undefined,
api_root: 'http://ws.audioscrobbler.com/2.0/', apiRoot: 'https://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', apiKey: '04d76faaac8726e60988e14c105d421a',
secret: 'a5d2a36fdf64819290f6982481eaffa2', secret: 'a5d2a36fdf64819290f6982481eaffa2',
}, },
listenbrainz: { listenbrainz: {
enabled: false, enabled: false,
token: undefined, token: undefined,
api_root: 'https://api.listenbrainz.org/1/', apiRoot: 'https://api.listenbrainz.org/1/',
}, },
}, },
}; };

View File

@ -1,10 +1,13 @@
import registerCallback, { type SongInfo } from '@/providers/song-info'; import { BrowserWindow } from 'electron';
import registerCallback, { MediaType, type SongInfo } from '@/providers/song-info';
import { createBackend } from '@/utils'; import { createBackend } from '@/utils';
import { ScrobblerPluginConfig } from './index';
import { LastFmScrobbler } from './services/lastfm'; import { LastFmScrobbler } from './services/lastfm';
import { ListenbrainzScrobbler } from './services/listenbrainz'; import { ListenbrainzScrobbler } from './services/listenbrainz';
import { ScrobblerBase } from './services/base';
import type { ScrobblerPluginConfig } from './index';
import type { ScrobblerBase } from './services/base';
export type SetConfType = ( export type SetConfType = (
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>, conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
@ -12,14 +15,17 @@ export type SetConfType = (
export const backend = createBackend<{ export const backend = createBackend<{
config?: ScrobblerPluginConfig; config?: ScrobblerPluginConfig;
window?: BrowserWindow;
enabledScrobblers: Map<string, ScrobblerBase>; enabledScrobblers: Map<string, ScrobblerBase>;
toggleScrobblers(config: ScrobblerPluginConfig): void; toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow): void;
createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<void>;
setConfig?: SetConfType;
}, ScrobblerPluginConfig>({ }, ScrobblerPluginConfig>({
enabledScrobblers: new Map(), enabledScrobblers: new Map(),
toggleScrobblers(config: ScrobblerPluginConfig) { toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) {
if (config.scrobblers.lastfm && config.scrobblers.lastfm.enabled) { if (config.scrobblers.lastfm && config.scrobblers.lastfm.enabled) {
this.enabledScrobblers.set('lastfm', new LastFmScrobbler()); this.enabledScrobblers.set('lastfm', new LastFmScrobbler(window));
} else { } else {
this.enabledScrobblers.delete('lastfm'); this.enabledScrobblers.delete('lastfm');
} }
@ -31,20 +37,27 @@ export const backend = createBackend<{
} }
}, },
async start({ async createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType) {
getConfig,
setConfig,
}) {
const config = this.config = await getConfig();
// This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined;
this.toggleScrobblers(config);
for (const [, scrobbler] of this.enabledScrobblers) { for (const [, scrobbler] of this.enabledScrobblers) {
if (!scrobbler.isSessionCreated(config)) { if (!scrobbler.isSessionCreated(config)) {
await scrobbler.createSession(config, setConfig); await scrobbler.createSession(config, setConfig);
} }
} }
},
async start({
getConfig,
setConfig,
window,
}) {
const config = this.config = await getConfig();
// This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined;
this.window = window;
this.toggleScrobblers(config, window);
await this.createSessions(config, setConfig);
this.setConfig = setConfig;
registerCallback((songInfo: SongInfo) => { registerCallback((songInfo: SongInfo) => {
// Set remove the old scrobble timer // Set remove the old scrobble timer
@ -52,7 +65,7 @@ export const backend = createBackend<{
if (!songInfo.isPaused) { if (!songInfo.isPaused) {
const configNonnull = this.config!; const configNonnull = this.config!;
// Scrobblers normally have no trouble working with official music videos // Scrobblers normally have no trouble working with official music videos
if (!configNonnull.scrobble_other_media && (songInfo.mediaType !== 'AUDIO' && songInfo.mediaType !== 'ORIGINAL_MUSIC_VIDEO')) { if (!configNonnull.scrobbleOtherMedia && (songInfo.mediaType !== MediaType.Audio && songInfo.mediaType !== MediaType.OriginalMusicVideo)) {
return; return;
} }
@ -71,12 +84,25 @@ export const backend = createBackend<{
}); });
}, },
onConfigChange(newConfig: ScrobblerPluginConfig) { async onConfigChange(newConfig: ScrobblerPluginConfig) {
this.enabledScrobblers.clear(); this.enabledScrobblers.clear();
this.config = newConfig; this.toggleScrobblers(newConfig, this.window!);
for (const [scrobblerName, scrobblerConfig] of Object.entries(newConfig.scrobblers)) {
if (scrobblerConfig.enabled) {
const scrobbler = this.enabledScrobblers.get(scrobblerName);
if (
this.config?.scrobblers?.[scrobblerName as keyof typeof newConfig.scrobblers]?.enabled !== scrobblerConfig.enabled &&
scrobbler &&
!scrobbler.isSessionCreated(newConfig) &&
this.setConfig
) {
await scrobbler.createSession(newConfig, this.setConfig);
}
}
}
this.toggleScrobblers(this.config); this.config = newConfig;
} }
}); });

View File

@ -20,7 +20,7 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
multiInputOptions: [ multiInputOptions: [
{ {
label: t('plugins.scrobbler.prompt.lastfm.api-key'), label: t('plugins.scrobbler.prompt.lastfm.api-key'),
value: options.scrobblers.lastfm?.api_key, value: options.scrobblers.lastfm?.apiKey,
inputAttrs: { inputAttrs: {
type: 'text' type: 'text'
} }
@ -42,7 +42,7 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
if (output) { if (output) {
if (output[0]) { if (output[0]) {
options.scrobblers.lastfm.api_key = output[0]; options.scrobblers.lastfm.apiKey = output[0];
} }
if (output[1]) { if (output[1]) {
@ -82,9 +82,9 @@ export const onMenu = async ({
{ {
label: t('plugins.scrobbler.menu.scrobble-other-media'), label: t('plugins.scrobbler.menu.scrobble-other-media'),
type: 'checkbox', type: 'checkbox',
checked: Boolean(config.scrobble_other_media), checked: Boolean(config.scrobbleOtherMedia),
click(item) { click(item) {
config.scrobble_other_media = item.checked; config.scrobbleOtherMedia = item.checked;
setConfig(config); setConfig(config);
}, },
}, },
@ -96,7 +96,7 @@ export const onMenu = async ({
type: 'checkbox', type: 'checkbox',
checked: Boolean(config.scrobblers.lastfm?.enabled), checked: Boolean(config.scrobblers.lastfm?.enabled),
click(item) { click(item) {
backend.toggleScrobblers(config); backend.toggleScrobblers(config, window);
config.scrobblers.lastfm.enabled = item.checked; config.scrobblers.lastfm.enabled = item.checked;
setConfig(config); setConfig(config);
}, },
@ -117,7 +117,7 @@ export const onMenu = async ({
type: 'checkbox', type: 'checkbox',
checked: Boolean(config.scrobblers.listenbrainz?.enabled), checked: Boolean(config.scrobblers.listenbrainz?.enabled),
click(item) { click(item) {
backend.toggleScrobblers(config); backend.toggleScrobblers(config, window);
config.scrobblers.listenbrainz.enabled = item.checked; config.scrobblers.listenbrainz.enabled = item.checked;
setConfig(config); setConfig(config);
}, },

View File

@ -1,6 +1,5 @@
import { ScrobblerPluginConfig } from '../index'; import type { ScrobblerPluginConfig } from '../index';
import { SetConfType } from '../main'; import type { SetConfType } from '../main';
import type { SongInfo } from '@/providers/song-info'; import type { SongInfo } from '@/providers/song-info';
export abstract class ScrobblerBase { export abstract class ScrobblerBase {

View File

@ -1,12 +1,13 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { net, shell } from 'electron'; import { BrowserWindow, dialog, net } from 'electron';
import { ScrobblerBase } from './base'; import { ScrobblerBase } from './base';
import { ScrobblerPluginConfig } from '../index'; import { t } from '@/i18n';
import { SetConfType } from '../main';
import type { ScrobblerPluginConfig } from '../index';
import type { SetConfType } from '../main';
import type { SongInfo } from '@/providers/song-info'; import type { SongInfo } from '@/providers/song-info';
interface LastFmData { interface LastFmData {
@ -28,21 +29,32 @@ interface LastFmSongData {
} }
export class LastFmScrobbler extends ScrobblerBase { export class LastFmScrobbler extends ScrobblerBase {
isSessionCreated(config: ScrobblerPluginConfig): boolean { mainWindow: BrowserWindow;
return !!config.scrobblers.lastfm.session_key;
constructor(mainWindow: BrowserWindow) {
super();
this.mainWindow = mainWindow;
} }
async createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig> { override isSessionCreated(config: ScrobblerPluginConfig): boolean {
return !!config.scrobblers.lastfm.sessionKey;
}
override async createSession(
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): Promise<ScrobblerPluginConfig> {
// Get and store the session key // Get and store the session key
const data = { const data = {
api_key: config.scrobblers.lastfm.api_key, api_key: config.scrobblers.lastfm.apiKey,
format: 'json', format: 'json',
method: 'auth.getsession', method: 'auth.getsession',
token: config.scrobblers.lastfm.token, token: config.scrobblers.lastfm.token,
}; };
const apiSignature = createApiSig(data, config.scrobblers.lastfm.secret); const apiSignature = createApiSig(data, config.scrobblers.lastfm.secret);
const response = await net.fetch( const response = await net.fetch(
`${config.scrobblers.lastfm.api_root}${createQueryString(data, apiSignature)}`, `${config.scrobblers.lastfm.apiRoot}${createQueryString(data, apiSignature)}`,
); );
const json = (await response.json()) as { const json = (await response.json()) as {
error?: string; error?: string;
@ -52,18 +64,25 @@ export class LastFmScrobbler extends ScrobblerBase {
}; };
if (json.error) { if (json.error) {
config.scrobblers.lastfm.token = await createToken(config); config.scrobblers.lastfm.token = await createToken(config);
await authenticate(config); // If is successful, we need retry the request
setConfig(config); authenticate(config, this.mainWindow).then((it) => {
if (it) {
this.createSession(config, setConfig);
} else {
// failed
setConfig(config);
}
});
} }
if (json.session) { if (json.session) {
config.scrobblers.lastfm.session_key = json.session.key; config.scrobblers.lastfm.sessionKey = json.session.key;
} }
setConfig(config); setConfig(config);
return config; return config;
} }
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
if (!config.scrobblers.lastfm.session_key) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -74,8 +93,8 @@ export class LastFmScrobbler extends ScrobblerBase {
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
if (!config.scrobblers.lastfm.session_key) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -87,14 +106,14 @@ export class LastFmScrobbler extends ScrobblerBase {
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
async postSongDataToAPI( private async postSongDataToAPI(
songInfo: SongInfo, songInfo: SongInfo,
config: ScrobblerPluginConfig, config: ScrobblerPluginConfig,
data: LastFmData, data: LastFmData,
setConfig: SetConfType, setConfig: SetConfType,
): Promise<void> { ): Promise<void> {
// This sends a post request to the api, and adds the common data // This sends a post request to the api, and adds the common data
if (!config.scrobblers.lastfm.session_key) { if (!config.scrobblers.lastfm.sessionKey) {
await this.createSession(config, setConfig); await this.createSession(config, setConfig);
} }
@ -103,8 +122,8 @@ export class LastFmScrobbler extends ScrobblerBase {
duration: songInfo.songDuration, duration: songInfo.songDuration,
artist: songInfo.artist, artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video ...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.scrobblers.lastfm.api_key, api_key: config.scrobblers.lastfm.apiKey,
sk: config.scrobblers.lastfm.session_key, sk: config.scrobblers.lastfm.sessionKey,
format: 'json', format: 'json',
...data, ...data,
}; };
@ -126,10 +145,16 @@ export class LastFmScrobbler extends ScrobblerBase {
}) => { }) => {
if (error?.response?.data?.error === 9) { if (error?.response?.data?.error === 9) {
// Session key is invalid, so remove it from the config and reauthenticate // Session key is invalid, so remove it from the config and reauthenticate
config.scrobblers.lastfm.session_key = undefined; config.scrobblers.lastfm.sessionKey = undefined;
config.scrobblers.lastfm.token = await createToken(config); config.scrobblers.lastfm.token = await createToken(config);
await authenticate(config); authenticate(config, this.mainWindow).then((it) => {
setConfig(config); if (it) {
this.createSession(config, setConfig);
} else {
// failed
setConfig(config);
}
});
} else { } else {
console.error(error); console.error(error);
} }
@ -168,17 +193,17 @@ const createQueryString = (
const createApiSig = (parameters: LastFmSongData, secret: string) => { const createApiSig = (parameters: LastFmSongData, secret: string) => {
// This function creates the api signature, see: https://www.last.fm/api/authspec // This function creates the api signature, see: https://www.last.fm/api/authspec
const keys = Object.keys(parameters);
keys.sort();
let sig = ''; let sig = '';
for (const key of keys) {
if (key === 'format') {
continue;
}
sig += `${key}${parameters[key as keyof LastFmSongData]}`; Object
} .entries(parameters)
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([key, value]) => {
if (key === 'format') {
return;
}
sig += key + value;
});
sig += secret; sig += secret;
sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex'); sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
@ -188,14 +213,18 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
const createToken = async ({ const createToken = async ({
scrobblers: { scrobblers: {
lastfm: { lastfm: {
api_key: apiKey, apiKey,
api_root: apiRoot, apiRoot,
secret, secret,
} }
} }
}: ScrobblerPluginConfig) => { }: ScrobblerPluginConfig) => {
// Creates and stores the auth token // Creates and stores the auth token
const data = { const data: {
method: string;
api_key: string;
format: string;
} = {
method: 'auth.gettoken', method: 'auth.gettoken',
api_key: apiKey, api_key: apiKey,
format: 'json', format: 'json',
@ -208,9 +237,68 @@ const createToken = async ({
return json?.token; return json?.token;
}; };
const authenticate = async (config: ScrobblerPluginConfig) => { let authWindowOpened = false;
// Asks the user for authentication let latestAuthResult = false;
await shell.openExternal(
`https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.api_key}&token=${config.scrobblers.lastfm.token}`, const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWindow) => {
); return new Promise<boolean>((resolve) => {
if (!authWindowOpened) {
authWindowOpened = true;
const url = `https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.apiKey}&token=${config.scrobblers.lastfm.token}`;
const browserWindow = new BrowserWindow({
width: 500,
height: 600,
show: false,
webPreferences: {
nodeIntegration: false,
},
autoHideMenuBar: true,
parent: mainWindow,
minimizable: false,
maximizable: false,
paintWhenInitiallyHidden: true,
modal: true,
center: true,
});
browserWindow.loadURL(url).then(() => {
browserWindow.show();
browserWindow.webContents.on('did-navigate', async (_, newUrl) => {
const url = new URL(newUrl);
if (url.hostname.endsWith('last.fm')) {
if (url.pathname === '/api/auth') {
const isApproveScreen = await browserWindow.webContents.executeJavaScript(
'!!document.getElementsByName(\'confirm\').length'
) as boolean;
// successful authentication
if (!isApproveScreen) {
resolve(true);
latestAuthResult = true;
browserWindow.close();
}
} else if (url.pathname === '/api/None') {
resolve(false);
latestAuthResult = false;
browserWindow.close();
}
}
});
browserWindow.on('closed', () => {
if (!latestAuthResult) {
dialog.showMessageBox({
title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'),
message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'),
type: 'error'
});
}
authWindowOpened = false;
});
});
} else {
// wait for the previous window to close
while (authWindowOpened) {
// wait
}
resolve(latestAuthResult);
}
});
}; };

View File

@ -2,10 +2,8 @@ import { net } from 'electron';
import { ScrobblerBase } from './base'; import { ScrobblerBase } from './base';
import { SetConfType } from '../main'; import type { SetConfType } from '../main';
import type { SongInfo } from '@/providers/song-info'; import type { SongInfo } from '@/providers/song-info';
import type { ScrobblerPluginConfig } from '../index'; import type { ScrobblerPluginConfig } from '../index';
interface ListenbrainzRequestBody { interface ListenbrainzRequestBody {
@ -27,16 +25,16 @@ interface ListenbrainzRequestBody {
} }
export class ListenbrainzScrobbler extends ScrobblerBase { export class ListenbrainzScrobbler extends ScrobblerBase {
isSessionCreated(): boolean { override isSessionCreated(): boolean {
return true; return true;
} }
createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> { override createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
return Promise.resolve(config); return Promise.resolve(config);
} }
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) { if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) {
return; return;
} }
@ -44,8 +42,8 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
submitListen(body, config); submitListen(body, config);
} }
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) { if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) {
return; return;
} }
@ -80,7 +78,7 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
} }
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) { function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) {
net.fetch(config.scrobblers.listenbrainz.api_root + 'submit-listens', net.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens',
{ {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),

View File

@ -4,10 +4,10 @@ declare module '@jellybrick/mpris-service' {
import { interface as dbusInterface } from 'dbus-next'; import { interface as dbusInterface } from 'dbus-next';
interface RootInterfaceOptions { interface RootInterfaceOptions {
identity: string; identity?: string;
supportedUriSchemes: string[]; supportedUriSchemes?: string[];
supportedMimeTypes: string[]; supportedMimeTypes?: string[];
desktopEntry: string; desktopEntry?: string;
} }
export interface Track { export interface Track {
@ -35,6 +35,32 @@ declare module '@jellybrick/mpris-service' {
'xesam:userRating'?: number; 'xesam:userRating'?: number;
} }
export type PlayBackStatus = 'Playing' | 'Paused' | 'Stopped';
export type LoopStatus = 'None' | 'Track' | 'Playlist';
export const PLAYBACK_STATUS_PLAYING: 'Playing';
export const PLAYBACK_STATUS_PAUSED: 'Paused';
export const PLAYBACK_STATUS_STOPPED: 'Stopped';
export const LOOP_STATUS_NONE: 'None';
export const LOOP_STATUS_TRACK: 'Track';
export const LOOP_STATUS_PLAYLIST: 'Playlist';
export type Interfaces = 'player' | 'trackList' | 'playlists';
export interface AdditionalPlayerOptions {
name: string;
supportedInterfaces: Interfaces[];
}
export type PlayerOptions = RootInterfaceOptions & AdditionalPlayerOptions;
export interface Position {
trackId: string;
position: number;
}
declare class Player extends EventEmitter { declare class Player extends EventEmitter {
constructor(opts: { constructor(opts: {
name: string; name: string;
@ -43,20 +69,46 @@ declare module '@jellybrick/mpris-service' {
supportedInterfaces?: string[]; supportedInterfaces?: string[];
}); });
//RootInterface
on(event: 'quit', listener: () => void): this;
on(event: 'raise', listener: () => void): this;
on(
event: 'fullscreen',
listener: (fullscreenEnabled: boolean) => void,
): this;
emit(type: string, ...args: unknown[]): unknown;
name: string; name: string;
identity: string; identity: string;
fullscreen: boolean; fullscreen?: boolean;
supportedUriSchemes: string[]; supportedUriSchemes: string[];
supportedMimeTypes: string[]; supportedMimeTypes: string[];
canQuit: boolean; canQuit: boolean;
canRaise: boolean; canRaise: boolean;
canSetFullscreen: boolean; canSetFullscreen?: boolean;
desktopEntry?: string;
hasTrackList: boolean; hasTrackList: boolean;
desktopEntry: string;
playbackStatus: string; // PlayerInterface
loopStatus: string; on(event: 'next', listener: () => void): this;
on(event: 'previous', listener: () => void): this;
on(event: 'pause', listener: () => void): this;
on(event: 'playpause', listener: () => void): this;
on(event: 'stop', listener: () => void): this;
on(event: 'play', listener: () => void): this;
on(event: 'seek', listener: (offset: number) => void): this;
on(event: 'open', listener: ({ uri: string }) => void): this;
on(event: 'loopStatus', listener: (status: LoopStatus) => void): this;
on(event: 'rate', listener: () => void): this;
on(event: 'shuffle', listener: (enableShuffle: boolean) => void): this;
on(event: 'volume', listener: (newVolume: number) => void): this;
on(event: 'position', listener: (position: Position) => void): this;
playbackStatus: PlayBackStatus;
loopStatus: LoopStatus;
shuffle: boolean; shuffle: boolean;
metadata: object; metadata: Track;
volume: number; volume: number;
canControl: boolean; canControl: boolean;
canPause: boolean; canPause: boolean;
@ -67,9 +119,40 @@ declare module '@jellybrick/mpris-service' {
rate: number; rate: number;
minimumRate: number; minimumRate: number;
maximumRate: number; maximumRate: number;
playlists: unknown[];
abstract getPosition(): number;
seeked(position: number): void;
// TracklistInterface
on(event: 'addTrack', listener: () => void): this;
on(event: 'removeTrack', listener: () => void): this;
on(event: 'goTo', listener: () => void): this;
tracks: Track[];
canEditTracks: boolean;
on(event: '*', a: unknown[]): this;
addTrack(track: string): void;
removeTrack(trackId: string): void;
// PlaylistsInterface
on(event: 'activatePlaylist', listener: () => void): this;
playlists: Playlist[];
activePlaylist: string; activePlaylist: string;
setPlaylists(playlists: Playlist[]): void;
setActivePlaylist(playlistId: string): void;
// Player methods
constructor(opts: PlayerOptions);
on(event: 'error', listener: (error: Error) => void): this;
init(opts: RootInterfaceOptions): void; init(opts: RootInterfaceOptions): void;
objectPath(subpath?: string): string; objectPath(subpath?: string): string;
@ -91,13 +174,6 @@ declare module '@jellybrick/mpris-service' {
setPlaylists(playlists: Track[]): void; setPlaylists(playlists: Track[]): void;
setActivePlaylist(playlistId: string): void; setActivePlaylist(playlistId: string): void;
static PLAYBACK_STATUS_PLAYING: 'Playing';
static PLAYBACK_STATUS_PAUSED: 'Paused';
static PLAYBACK_STATUS_STOPPED: 'Stopped';
static LOOP_STATUS_NONE: 'None';
static LOOP_STATUS_TRACK: 'Track';
static LOOP_STATUS_PLAYLIST: 'Playlist';
} }
interface MprisInterface extends dbusInterface.Interface { interface MprisInterface extends dbusInterface.Interface {

View File

@ -1,37 +1,117 @@
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow, ipcMain } from 'electron';
import mpris, { Track } from '@jellybrick/mpris-service'; import MprisPlayer, {
Track,
LoopStatus,
type PlayBackStatus,
type PlayerOptions,
PLAYBACK_STATUS_STOPPED,
PLAYBACK_STATUS_PAUSED,
PLAYBACK_STATUS_PLAYING,
LOOP_STATUS_NONE,
LOOP_STATUS_PLAYLIST,
LOOP_STATUS_TRACK,
type Position,
} from '@jellybrick/mpris-service';
import registerCallback from '@/providers/song-info'; import registerCallback, { type SongInfo } from '@/providers/song-info';
import getSongControls from '@/providers/song-controls'; import getSongControls from '@/providers/song-controls';
import config from '@/config'; import config from '@/config';
import { LoggerPrefix } from '@/utils';
import type { RepeatMode } from '@/types/datahost-get-state';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
class YTPlayer extends MprisPlayer {
/**
* @type {number} The current position in microseconds
* @private
*/
private currentPosition: number;
constructor(opts: PlayerOptions) {
super(opts);
this.currentPosition = 0;
}
setPosition(t: number) {
this.currentPosition = t;
}
override getPosition(): number {
return this.currentPosition;
}
setLoopStatus(status: LoopStatus) {
this.loopStatus = status;
}
isPlaying(): boolean {
return this.playbackStatus === PLAYBACK_STATUS_PLAYING;
}
isPaused(): boolean {
return this.playbackStatus === PLAYBACK_STATUS_PAUSED;
}
isStopped(): boolean {
return this.playbackStatus === PLAYBACK_STATUS_STOPPED;
}
setPlaybackStatus(status: PlayBackStatus) {
this.playbackStatus = status;
}
}
function setupMPRIS() { function setupMPRIS() {
const instance = new mpris({ const instance = new YTPlayer({
name: 'youtube-music', name: 'YoutubeMusic',
identity: 'YouTube Music', identity: 'YouTube Music',
supportedMimeTypes: ['audio/mpeg'], supportedMimeTypes: ['audio/mpeg'],
supportedInterfaces: ['player'], supportedInterfaces: ['player'],
}); });
instance.canRaise = true; instance.canRaise = true;
instance.supportedUriSchemes = ['https']; instance.canQuit = false;
instance.canSetFullscreen = true;
instance.supportedUriSchemes = ['http', 'https'];
instance.desktopEntry = 'youtube-music'; instance.desktopEntry = 'youtube-music';
return instance; return instance;
} }
function registerMPRIS(win: BrowserWindow) { function registerMPRIS(win: BrowserWindow) {
const songControls = getSongControls(win); const songControls = getSongControls(win);
const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = const {
songControls; playPause,
next,
previous,
setVolume,
shuffle,
switchRepeat,
setFullscreen,
requestFullscreenInformation,
requestQueueInformation,
} = songControls;
try { try {
// TODO: "Typing" for this arguments let currentSongInfo: SongInfo | null = null;
const secToMicro = (n: unknown) => Math.round(Number(n) * 1e6); const secToMicro = (n: number) => Math.round(Number(n) * 1e6);
const microToSec = (n: unknown) => Math.round(Number(n) / 1e6); const microToSec = (n: number) => Math.round(Number(n) / 1e6);
const seekTo = (e: { position: unknown }) => const correctId = (videoId: string) => {
win.webContents.send('ytmd:seek-to', microToSec(e.position)); return videoId.replace('-', '_MINUS_');
const seekBy = (o: unknown) => };
win.webContents.send('ytmd:seek-by', microToSec(o));
const seekTo = (event: Position) => {
if (
currentSongInfo?.videoId &&
event.trackId.endsWith(correctId(currentSongInfo.videoId))
) {
win.webContents.send('ytmd:seek-to', microToSec(event.position ?? 0));
}
};
const seekBy = (offset: number) =>
win.webContents.send('ytmd:seek-by', microToSec(offset));
const player = setupMPRIS(); const player = setupMPRIS();
@ -40,73 +120,145 @@ function registerMPRIS(win: BrowserWindow) {
win.webContents.send('ytmd:setup-time-changed-listener', 'mpris'); win.webContents.send('ytmd:setup-time-changed-listener', 'mpris');
win.webContents.send('ytmd:setup-repeat-changed-listener', 'mpris'); win.webContents.send('ytmd:setup-repeat-changed-listener', 'mpris');
win.webContents.send('ytmd:setup-volume-changed-listener', 'mpris'); win.webContents.send('ytmd:setup-volume-changed-listener', 'mpris');
win.webContents.send('ytmd:setup-fullscreen-changed-listener', 'mpris');
win.webContents.send('ytmd:setup-autoplay-changed-listener', 'mpris');
requestFullscreenInformation();
requestQueueInformation();
}); });
ipcMain.on('ytmd:seeked', (_, t: number) => player.seeked(secToMicro(t))); ipcMain.on('ytmd:seeked', (_, t: number) => player.seeked(secToMicro(t)));
let currentSeconds = 0; ipcMain.on('ytmd:time-changed', (_, t: number) => {
ipcMain.on('ytmd:time-changed', (_, t: number) => (currentSeconds = t)); player.setPosition(secToMicro(t));
});
ipcMain.on('ytmd:repeat-changed', (_, mode: string) => { ipcMain.on('ytmd:repeat-changed', (_, mode: RepeatMode) => {
switch (mode) { switch (mode) {
case 'NONE': { case 'NONE': {
player.loopStatus = mpris.LOOP_STATUS_NONE; player.setLoopStatus(LOOP_STATUS_NONE);
break; break;
} }
case 'ONE': { case 'ONE': {
player.loopStatus = mpris.LOOP_STATUS_TRACK; player.setLoopStatus(LOOP_STATUS_TRACK);
break; break;
} }
case 'ALL': { case 'ALL': {
player.loopStatus = mpris.LOOP_STATUS_PLAYLIST; player.setLoopStatus(LOOP_STATUS_PLAYLIST);
// No default // No default
break; break;
} }
} }
requestQueueInformation();
}); });
player.on('loopStatus', (status: string) => {
ipcMain.on('ytmd:fullscreen-changed', (_, changedTo: boolean) => {
if (player.fullscreen === undefined || !player.canSetFullscreen) {
return;
}
player.fullscreen =
changedTo !== undefined ? changedTo : !player.fullscreen;
});
ipcMain.on(
'ytmd:set-fullscreen',
(_, isFullscreen: boolean | undefined) => {
if (!player.canSetFullscreen || isFullscreen === undefined) {
return;
}
player.fullscreen = isFullscreen;
},
);
ipcMain.on(
'ytmd:fullscreen-changed-supported',
(_, isFullscreenSupported: boolean) => {
player.canSetFullscreen = isFullscreenSupported;
},
);
ipcMain.on('ytmd:autoplay-changed', (_) => {
requestQueueInformation();
});
ipcMain.on('ytmd:get-queue-response', (_, queue: QueueResponse) => {
if (!queue) {
return;
}
const currentPosition = queue.items?.findIndex((it) =>
it?.playlistPanelVideoRenderer?.selected ||
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer?.playlistPanelVideoRenderer?.selected
) ?? 0;
player.canGoPrevious = currentPosition !== 0;
let hasNext: boolean;
if (queue.autoPlaying) {
hasNext = true;
} else if (player.loopStatus === LOOP_STATUS_PLAYLIST) {
hasNext = true;
} else {
// Example: currentPosition = 0, queue.items.length = 29 -> hasNext = true
hasNext = !!(currentPosition - (queue?.items?.length ?? 0 - 1));
}
player.canGoNext = hasNext;
});
player.on('loopStatus', (status: LoopStatus) => {
// SwitchRepeat cycles between states in that order // SwitchRepeat cycles between states in that order
const switches = [ const switches = [
mpris.LOOP_STATUS_NONE, LOOP_STATUS_NONE,
mpris.LOOP_STATUS_PLAYLIST, LOOP_STATUS_PLAYLIST,
mpris.LOOP_STATUS_TRACK, LOOP_STATUS_TRACK,
]; ];
const currentIndex = switches.indexOf(player.loopStatus); const currentIndex = switches.indexOf(player.loopStatus);
const targetIndex = switches.indexOf(status); const targetIndex = switches.indexOf(status);
// Get a delta in the range [0,2] // Get a delta in the range [0,2]
const delta = (targetIndex - currentIndex + 3) % 3; const delta = (targetIndex - currentIndex + 3) % 3;
songControls.switchRepeat(delta); switchRepeat(delta);
}); });
player.getPosition = () => secToMicro(currentSeconds);
player.on('raise', () => { player.on('raise', () => {
if (!player.canRaise) {
return;
}
win.setSkipTaskbar(false); win.setSkipTaskbar(false);
win.show(); win.show();
}); });
player.on('fullscreen', (fullscreenEnabled: boolean) => {
setFullscreen(fullscreenEnabled);
});
player.on('play', () => { player.on('play', () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PLAYING) { if (!player.isPlaying()) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PLAYING; player.setPlaybackStatus(PLAYBACK_STATUS_PLAYING);
playPause(); playPause();
} }
}); });
player.on('pause', () => { player.on('pause', () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PAUSED) { if (!player.isPaused()) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PAUSED; player.setPlaybackStatus(PLAYBACK_STATUS_PAUSED);
playPause(); playPause();
} }
}); });
player.on('playpause', () => { player.on('playpause', () => {
player.playbackStatus = player.setPlaybackStatus(
player.playbackStatus === mpris.PLAYBACK_STATUS_PLAYING player.isPlaying() ? PLAYBACK_STATUS_PAUSED : PLAYBACK_STATUS_PLAYING,
? mpris.PLAYBACK_STATUS_PAUSED );
: mpris.PLAYBACK_STATUS_PLAYING;
playPause(); playPause();
}); });
player.on('next', next); player.on('next', () => {
player.on('previous', previous); next();
});
player.on('previous', () => {
previous();
});
player.on('seek', seekBy); player.on('seek', seekBy);
player.on('position', seekTo); player.on('position', seekTo);
@ -114,10 +266,18 @@ function registerMPRIS(win: BrowserWindow) {
player.on('shuffle', (enableShuffle) => { player.on('shuffle', (enableShuffle) => {
if (enableShuffle) { if (enableShuffle) {
shuffle(); shuffle();
requestQueueInformation();
} }
}); });
player.on('open', (args: { uri: string }) => { player.on('open', (args: { uri: string }) => {
win.loadURL(args.uri); win.loadURL(args.uri).then(() => {
requestQueueInformation();
});
});
player.on('error', (error: Error) => {
console.error(LoggerPrefix, 'Error in MPRIS');
console.trace(error);
}); });
let mprisVolNewer = false; let mprisVolNewer = false;
@ -136,7 +296,7 @@ function registerMPRIS(win: BrowserWindow) {
} }
}); });
player.on('volume', (newVolume) => { player.on('volume', (newVolume: number) => {
if (config.plugins.isEnabled('precise-volume')) { if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value. // With precise volume we can set the volume to the exact value.
const newVol = ~~(newVolume * 100); const newVol = ~~(newVolume * 100);
@ -146,45 +306,46 @@ function registerMPRIS(win: BrowserWindow) {
win.webContents.send('setVolume', newVol); win.webContents.send('setVolume', newVol);
} }
} else { } else {
// With keyboard shortcuts we can only change the volume in increments of 10, so round it. setVolume(newVolume * 100);
let deltaVolume = Math.round((newVolume - player.volume) * 10);
while (deltaVolume !== 0 && deltaVolume > 0) {
volumePlus10();
player.volume += 0.1;
deltaVolume--;
}
while (deltaVolume !== 0 && deltaVolume < 0) {
volumeMinus10();
player.volume -= 0.1;
deltaVolume++;
}
} }
}); });
registerCallback((songInfo) => { registerCallback((songInfo: SongInfo) => {
if (player) { if (player) {
const data: Track = { const data: Track = {
'mpris:length': secToMicro(songInfo.songDuration), 'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc ?? undefined, ...(songInfo.imageSrc
? { 'mpris:artUrl': songInfo.imageSrc }
: undefined),
'xesam:title': songInfo.title, 'xesam:title': songInfo.title,
'xesam:url': songInfo.url, 'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist], 'xesam:artist': [songInfo.artist],
'mpris:trackid': '/', 'mpris:trackid': player.objectPath(
`Track/${correctId(songInfo.videoId)}`,
),
}; };
if (songInfo.album) { if (songInfo.album) {
data['xesam:album'] = songInfo.album; data['xesam:album'] = songInfo.album;
} }
currentSongInfo = songInfo;
player.metadata = data; player.metadata = data;
player.seeked(secToMicro(songInfo.elapsedSeconds));
player.playbackStatus = songInfo.isPaused const currentElapsedMicroSeconds = secToMicro(
? mpris.PLAYBACK_STATUS_PAUSED songInfo.elapsedSeconds ?? 0,
: mpris.PLAYBACK_STATUS_PLAYING; );
player.setPosition(currentElapsedMicroSeconds);
player.seeked(currentElapsedMicroSeconds);
player.setPlaybackStatus(
songInfo.isPaused ? PLAYBACK_STATUS_PAUSED : PLAYBACK_STATUS_PLAYING,
);
} }
requestQueueInformation();
}); });
} catch (error) { } catch (error) {
console.warn('Error in MPRIS', error); console.error(LoggerPrefix, 'Error in MPRIS');
console.trace(error);
} }
} }

View File

@ -1,18 +1,37 @@
import { t } from '@/i18n'; import { t } from '@/i18n';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
export default createPlugin({ export default createPlugin<
unknown,
unknown,
{
observer?: MutationObserver;
waitForElem(selector: string): Promise<HTMLElement>;
start(): void;
stop(): void;
}
>({
name: () => t('plugins.skip-disliked-songs.name'), name: () => t('plugins.skip-disliked-songs.name'),
description: () => t('plugins.skip-disliked-songs.description'), description: () => t('plugins.skip-disliked-songs.description'),
restartNeeded: false, restartNeeded: false,
renderer: { renderer: {
observer: null as MutationObserver | null, waitForElem(selector: string) {
return new Promise<HTMLElement>((resolve) => {
const interval = setInterval(() => {
const elem = document.querySelector<HTMLElement>(selector);
if (!elem) return;
clearInterval(interval);
resolve(elem);
});
});
},
start() { start() {
this.waitForElem('#like-button-renderer').then((likeBtn) => { this.waitForElem('#like-button-renderer').then((likeBtn) => {
this.observer = new MutationObserver(() => { this.observer = new MutationObserver(() => {
if (likeBtn?.getAttribute('like-status') == 'DISLIKE') { if (likeBtn?.getAttribute('like-status') == 'DISLIKE') {
document document
.querySelector('tp-yt-paper-icon-button.next-button') .querySelector<HTMLButtonElement>('tp-yt-paper-icon-button.next-button')
?.click(); ?.click();
} }
}); });
@ -26,16 +45,5 @@ export default createPlugin({
stop() { stop() {
this.observer?.disconnect(); this.observer?.disconnect();
}, },
waitForElem(selector) {
return new Promise((resolve) => {
const interval = setInterval(() => {
const elem = document.querySelector(selector);
if (!elem) return;
clearInterval(interval);
resolve(elem);
});
});
},
}, },
}); });

View File

@ -6,7 +6,7 @@ import getSongControls from './song-controls';
export const APP_PROTOCOL = 'youtubemusic'; export const APP_PROTOCOL = 'youtubemusic';
let protocolHandler: ((cmd: string) => void) | undefined; let protocolHandler: ((cmd: string, args: string[] | undefined) => void) | undefined;
export function setupProtocolHandler(win: BrowserWindow) { export function setupProtocolHandler(win: BrowserWindow) {
if (process.defaultApp && process.argv.length >= 2) { if (process.defaultApp && process.argv.length >= 2) {
@ -19,18 +19,18 @@ export function setupProtocolHandler(win: BrowserWindow) {
const songControls = getSongControls(win); const songControls = getSongControls(win);
protocolHandler = ((cmd: keyof typeof songControls) => { protocolHandler = ((cmd: keyof typeof songControls, args: string[] | undefined = undefined) => {
if (Object.keys(songControls).includes(cmd)) { if (Object.keys(songControls).includes(cmd)) {
songControls[cmd](); songControls[cmd](args as never);
} }
}) as (cmd: string) => void; }) as (cmd: string) => void;
} }
export function handleProtocol(cmd: string) { export function handleProtocol(cmd: string, args: string[] | undefined) {
protocolHandler?.(cmd); protocolHandler?.(cmd, args);
} }
export function changeProtocolHandler(f: (cmd: string) => void) { export function changeProtocolHandler(f: (cmd: string, args: string[] | undefined) => void) {
protocolHandler = f; protocolHandler = f;
} }

View File

@ -1,39 +1,82 @@
// This is used for to control the songs // This is used for to control the songs
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow } from 'electron';
// see protocol-handler.ts
type ArgsType<T> = T | string[] | undefined;
const parseNumberFromArgsType = (args: ArgsType<number>) => {
if (typeof args === 'number') {
return args;
} else if (Array.isArray(args)) {
return Number(args[0]);
} else {
return null;
}
};
const parseBooleanFromArgsType = (args: ArgsType<boolean>) => {
if (typeof args === 'boolean') {
return args;
} else if (Array.isArray(args)) {
return args[0] === 'true';
} else {
return null;
}
};
export default (win: BrowserWindow) => { export default (win: BrowserWindow) => {
const commands = {
// Playback
previous: () => win.webContents.send('ytmd:previous-video'),
next: () => win.webContents.send('ytmd:next-video'),
playPause: () => win.webContents.send('ytmd:toggle-play'),
like: () => win.webContents.send('ytmd:update-like', 'LIKE'),
dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'),
go10sBack: () => win.webContents.send('ytmd:seek-by', -10),
go10sForward: () => win.webContents.send('ytmd:seek-by', 10),
go1sBack: () => win.webContents.send('ytmd:seek-by', -1),
go1sForward: () => win.webContents.send('ytmd:seek-by', 1),
shuffle: () => win.webContents.send('ytmd:shuffle'),
switchRepeat: (n = 1) => win.webContents.send('ytmd:switch-repeat', n),
// General
volumeMinus10: () => {
ipcMain.once('ytmd:get-volume-return', (_, volume) => {
win.webContents.send('ytmd:update-volume', volume - 10);
});
win.webContents.send('ytmd:get-volume');
},
volumePlus10: () => {
ipcMain.once('ytmd:get-volume-return', (_, volume) => {
win.webContents.send('ytmd:update-volume', volume + 10);
});
win.webContents.send('ytmd:get-volume');
},
fullscreen: () => win.webContents.send('ytmd:toggle-fullscreen'),
muteUnmute: () => win.webContents.send('ytmd:toggle-mute'),
};
return { return {
...commands, // Playback
play: commands.playPause, previous: () => win.webContents.send('ytmd:previous-video'),
pause: commands.playPause, next: () => win.webContents.send('ytmd:next-video'),
playPause: () => win.webContents.send('ytmd:toggle-play'),
like: () => win.webContents.send('ytmd:update-like', 'LIKE'),
dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'),
goBack: (seconds: ArgsType<number>) => {
const secondsNumber = parseNumberFromArgsType(seconds);
if (secondsNumber !== null) {
win.webContents.send('ytmd:seek-by', -secondsNumber);
}
},
goForward: (seconds: ArgsType<number>) => {
const secondsNumber = parseNumberFromArgsType(seconds);
if (secondsNumber !== null) {
win.webContents.send('ytmd:seek-by', seconds);
}
},
shuffle: () => win.webContents.send('ytmd:shuffle'),
switchRepeat: (n: ArgsType<number> = 1) => {
const repeat = parseNumberFromArgsType(n);
if (repeat !== null) {
win.webContents.send('ytmd:switch-repeat', n);
}
},
// General
setVolume: (volume: ArgsType<number>) => {
const volumeNumber = parseNumberFromArgsType(volume);
if (volumeNumber !== null) {
win.webContents.send('ytmd:update-volume', volume);
}
},
setFullscreen: (isFullscreen: ArgsType<boolean>) => {
const isFullscreenValue = parseBooleanFromArgsType(isFullscreen);
if (isFullscreenValue !== null) {
win.setFullScreen(isFullscreenValue);
win.webContents.send('ytmd:click-fullscreen-button', isFullscreenValue);
}
},
requestFullscreenInformation: () => {
win.webContents.send('ytmd:get-fullscreen');
},
requestQueueInformation: () => {
win.webContents.send('ytmd:get-queue');
},
muteUnmute: () => win.webContents.send('ytmd:toggle-mute'),
search: () => {
win.webContents.sendInputEvent({
type: 'keyDown',
keyCode: '/',
});
},
}; };
}; };

View File

@ -29,8 +29,9 @@ export const setupTimeChangedListener = singleton(() => {
const progressObserver = new MutationObserver((mutations) => { const progressObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
const target = mutation.target as Node & { value: string }; const target = mutation.target as Node & { value: string };
window.ipcRenderer.send('ytmd:time-changed', target.value); const numberValue = Number(target.value);
songInfo.elapsedSeconds = Number(target.value); window.ipcRenderer.send('ytmd:time-changed', numberValue);
songInfo.elapsedSeconds = numberValue;
} }
}); });
const progressBar = document.querySelector('#progress-bar'); const progressBar = document.querySelector('#progress-bar');
@ -61,11 +62,13 @@ export const setupRepeatChangedListener = singleton(() => {
// provided by YouTube Music // provided by YouTube Music
window.ipcRenderer.send( window.ipcRenderer.send(
'ytmd:repeat-changed', 'ytmd:repeat-changed',
document.querySelector< document
HTMLElement & { .querySelector<
getState: () => GetState; HTMLElement & {
} getState: () => GetState;
>('ytmusic-player-bar')?.getState().queue.repeatMode, }
>('ytmusic-player-bar')
?.getState().queue.repeatMode,
); );
}); });
@ -77,6 +80,46 @@ export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
window.ipcRenderer.send('ytmd:volume-changed', api.getVolume()); window.ipcRenderer.send('ytmd:volume-changed', api.getVolume());
}); });
export const setupFullScreenChangedListener = singleton(() => {
const playerBar = document.querySelector('ytmusic-player-bar');
if (!playerBar) {
window.ipcRenderer.send('ytmd:fullscreen-changed-supported', false);
return;
}
const observer = new MutationObserver(() => {
window.ipcRenderer.send(
'ytmd:fullscreen-changed',
(
playerBar?.attributes.getNamedItem('player-fullscreened') ?? null
) !== null,
);
});
observer.observe(playerBar, {
attributes: true,
childList: false,
subtree: false,
});
});
export const setupAutoPlayChangedListener = singleton(() => {
const autoplaySlider = document.querySelector<HTMLInputElement>(
'.autoplay > tp-yt-paper-toggle-button',
);
const observer = new MutationObserver(() => {
window.ipcRenderer.send('ytmd:autoplay-changed');
});
observer.observe(autoplaySlider!, {
attributes: true,
childList: false,
subtree: false,
});
});
export default (api: YoutubePlayer) => { export default (api: YoutubePlayer) => {
window.ipcRenderer.on('ytmd:setup-time-changed-listener', () => { window.ipcRenderer.on('ytmd:setup-time-changed-listener', () => {
setupTimeChangedListener(); setupTimeChangedListener();
@ -90,6 +133,14 @@ export default (api: YoutubePlayer) => {
setupVolumeChangedListener(api); setupVolumeChangedListener(api);
}); });
window.ipcRenderer.on('ytmd:setup-fullscreen-changed-listener', () => {
setupFullScreenChangedListener();
});
window.ipcRenderer.on('ytmd:setup-autoplay-changed-listener', () => {
setupAutoPlayChangedListener();
});
window.ipcRenderer.on('ytmd:setup-seeked-listener', () => { window.ipcRenderer.on('ytmd:setup-seeked-listener', () => {
setupSeekedListener(); setupSeekedListener();
}); });
@ -154,13 +205,13 @@ export default (api: YoutubePlayer) => {
function sendSongInfo(videoData: VideoDataChangeValue) { function sendSongInfo(videoData: VideoDataChangeValue) {
const data = api.getPlayerResponse(); const data = api.getPlayerResponse();
data.videoDetails.album = data.videoDetails.album = (
( Object.entries(videoData).find(
Object.entries(videoData) ([, value]) => value && Object.hasOwn(value, 'playerOverlays'),
.find(([, value]) => value && Object.hasOwn(value, 'playerOverlays')) as [string, AlbumDetails | undefined] ) as [string, AlbumDetails | undefined]
)?.[1]?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at( )?.[1]?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at(
0, 0,
)?.text; )?.text;
data.videoDetails.elapsedSeconds = 0; data.videoDetails.elapsedSeconds = 0;
data.videoDetails.isPaused = false; data.videoDetails.isPaused = false;

View File

@ -7,7 +7,7 @@ import config from '@/config';
import type { GetPlayerResponse } from '@/types/get-player-response'; import type { GetPlayerResponse } from '@/types/get-player-response';
enum MediaType { export enum MediaType {
/** /**
* Audio uploaded by the original artist * Audio uploaded by the original artist
*/ */
@ -120,11 +120,24 @@ const handleData = async (
songInfo.mediaType = MediaType.PodcastEpisode; songInfo.mediaType = MediaType.PodcastEpisode;
// HACK: Podcast's participant is not the artist // HACK: Podcast's participant is not the artist
if (!config.get('options.usePodcastParticipantAsArtist')) { if (!config.get('options.usePodcastParticipantAsArtist')) {
songInfo.artist = cleanupName(data.microformat.microformatDataRenderer.pageOwnerDetails.name); songInfo.artist = cleanupName(
data.microformat.microformatDataRenderer.pageOwnerDetails.name,
);
} }
break; break;
default: default:
songInfo.mediaType = MediaType.OtherVideo; songInfo.mediaType = MediaType.OtherVideo;
// HACK: This is a workaround for "podcast" types where "musicVideoType" doesn't exist. Google :facepalm:
if (
!config.get('options.usePodcastParticipantAsArtist') &&
(data.responseContext.serviceTrackingParams
?.at(0)
?.params?.find((it) => it.key === 'ipcc')?.value ?? '1') != '0'
) {
songInfo.artist = cleanupName(
data.microformat.microformatDataRenderer.pageOwnerDetails.name,
);
}
break; break;
} }
@ -153,10 +166,12 @@ const registerProvider = (win: BrowserWindow) => {
// This will be called when the song-info-front finds a new request with song data // This will be called when the song-info-front finds a new request with song data
ipcMain.on('ytmd:video-src-changed', async (_, data: GetPlayerResponse) => { ipcMain.on('ytmd:video-src-changed', async (_, data: GetPlayerResponse) => {
const tempSongInfo = await dataMutex.runExclusive<SongInfo | null>(async () => { const tempSongInfo = await dataMutex.runExclusive<SongInfo | null>(
songInfo = await handleData(data, win); async () => {
return songInfo; songInfo = await handleData(data, win);
}); return songInfo;
},
);
if (tempSongInfo) { if (tempSongInfo) {
for (const c of callbacks) { for (const c of callbacks) {

View File

@ -15,6 +15,8 @@ import { loadI18n, setLanguage, t as i18t } from '@/i18n';
import type { PluginConfig } from '@/types/plugins'; import type { PluginConfig } from '@/types/plugins';
import type { YoutubePlayer } from '@/types/youtube-player'; import type { YoutubePlayer } from '@/types/youtube-player';
import type { QueueElement } from '@/types/queue';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
let api: (Element & YoutubePlayer) | null = null; let api: (Element & YoutubePlayer) | null = null;
let isPluginLoaded = false; let isPluginLoaded = false;
@ -61,18 +63,56 @@ async function onApiLoaded() {
} }
}); });
window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => { window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => {
document.querySelector<HTMLElement & { updateVolume: (volume: number) => void }>('ytmusic-player-bar')?.updateVolume(volume); document
.querySelector<
HTMLElement & { updateVolume: (volume: number) => void }
>('ytmusic-player-bar')
?.updateVolume(volume);
}); });
window.ipcRenderer.on('ytmd:get-volume', (event) => {
event.sender.emit('ytmd:get-volume-return', api?.getVolume()); const isFullscreen = () => {
const isFullscreen =
document
.querySelector<HTMLElement>('ytmusic-player-bar')
?.attributes.getNamedItem('player-fullscreened') ?? null;
return isFullscreen !== null;
};
const clickFullscreenButton = (isFullscreenValue: boolean) => {
const fullscreen = isFullscreen();
if (isFullscreenValue === fullscreen) {
return;
}
if (fullscreen) {
document.querySelector<HTMLElement>('.exit-fullscreen-button')?.click();
} else {
document.querySelector<HTMLElement>('.fullscreen-button')?.click();
}
};
window.ipcRenderer.on('ytmd:get-fullscreen', (event) => {
event.sender.send('ytmd:set-fullscreen', isFullscreen());
}); });
window.ipcRenderer.on('ytmd:toggle-fullscreen', (_) => {
document.querySelector<HTMLElement & { toggleFullscreen: () => void }>('ytmusic-player-bar')?.toggleFullscreen(); window.ipcRenderer.on('ytmd:click-fullscreen-button', (_, fullscreen: boolean | undefined) => {
clickFullscreenButton(fullscreen ?? false);
}); });
window.ipcRenderer.on('ytmd:toggle-mute', (_) => { window.ipcRenderer.on('ytmd:toggle-mute', (_) => {
document.querySelector<HTMLElement & { onVolumeTap: () => void }>('ytmusic-player-bar')?.onVolumeTap(); document.querySelector<HTMLElement & { onVolumeTap: () => void }>('ytmusic-player-bar')?.onVolumeTap();
}); });
window.ipcRenderer.on('ytmd:get-queue', (event) => {
const queue = document.querySelector<QueueElement>('#queue');
event.sender.send('ytmd:get-queue-response', {
items: queue?.queue.getItems(),
autoPlaying: queue?.queue.autoPlaying,
continuation: queue?.queue.continuation,
} satisfies QueueResponse);
});
const video = document.querySelector('video')!; const video = document.querySelector('video')!;
const audioContext = new AudioContext(); const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video); const audioSource = audioContext.createMediaElementSource(video);
@ -133,7 +173,7 @@ async function onApiLoaded() {
// Remove upgrade button // Remove upgrade button
if (window.mainConfig.get('options.removeUpgradeButton')) { if (window.mainConfig.get('options.removeUpgradeButton')) {
const styles = document.createElement('style'); const styles = document.createElement('style');
styles.innerHTML = `ytmusic-guide-signin-promo-renderer { styles.innerHTML = `ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer:last-child {
display: none; display: none;
}`; }`;
document.head.appendChild(styles); document.head.appendChild(styles);
@ -236,7 +276,9 @@ const initObserver = async () => {
// check document.documentElement is ready // check document.documentElement is ready
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }); document.addEventListener('DOMContentLoaded', () => resolve(), {
once: true,
});
} else { } else {
resolve(); resolve();
} }

View File

@ -1,3 +1,5 @@
import type { PlayerConfig } from '@/types/get-player-response';
export interface GetState { export interface GetState {
castStatus: CastStatus; castStatus: CastStatus;
entities: Entities; entities: Entities;
@ -32,17 +34,11 @@ export interface Download {
export interface Entities {} export interface Entities {}
export interface LikeStatus { export interface LikeStatus {
videos: Videos; videos: Record<string, LikeType>;
playlists: Entities; playlists: Entities;
} }
export interface Videos { export enum LikeType {
tNVTuUEeWP0: Kqp1PyPRBzA;
KQP1PyPrBzA: Kqp1PyPRBzA;
'o1iz4L-5zkQ': Kqp1PyPRBzA;
}
export enum Kqp1PyPRBzA {
Dislike = 'DISLIKE', Dislike = 'DISLIKE',
Indifferent = 'INDIFFERENT', Indifferent = 'INDIFFERENT',
Like = 'LIKE', Like = 'LIKE',
@ -195,14 +191,10 @@ export interface Target {
export interface CommandWatchEndpoint { export interface CommandWatchEndpoint {
videoId: string; videoId: string;
params: PurpleParams; params: string;
watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs;
} }
export enum PurpleParams {
WAEB = 'wAEB',
}
export interface PurpleWatchEndpointMusicSupportedConfigs { export interface PurpleWatchEndpointMusicSupportedConfigs {
watchEndpointMusicConfig: PurpleWatchEndpointMusicConfig; watchEndpointMusicConfig: PurpleWatchEndpointMusicConfig;
} }
@ -381,7 +373,7 @@ export enum SharePanelType {
export interface PurpleWatchEndpoint { export interface PurpleWatchEndpoint {
videoId: string; videoId: string;
playlistId: string; playlistId: string;
params: PurpleParams; params: string;
loggingContext: LoggingContext; loggingContext: LoggingContext;
watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs;
} }
@ -466,7 +458,7 @@ export interface FeedbackEndpoint {
} }
export interface PurpleLikeEndpoint { export interface PurpleLikeEndpoint {
status: Kqp1PyPRBzA; status: LikeType;
target: Target; target: Target;
actions?: LikeEndpointAction[]; actions?: LikeEndpointAction[];
} }
@ -488,7 +480,7 @@ export interface PurpleToggledServiceEndpoint {
} }
export interface FluffyLikeEndpoint { export interface FluffyLikeEndpoint {
status: Kqp1PyPRBzA; status: LikeType;
target: Target; target: Target;
} }
@ -690,7 +682,7 @@ export interface FluffyDefaultServiceEndpoint {
} }
export interface TentacledLikeEndpoint { export interface TentacledLikeEndpoint {
status: Kqp1PyPRBzA; status: LikeType;
target: AddToPlaylistEndpoint; target: AddToPlaylistEndpoint;
actions?: LikeEndpointAction[]; actions?: LikeEndpointAction[];
} }
@ -702,7 +694,7 @@ export interface FluffyToggledServiceEndpoint {
} }
export interface StickyLikeEndpoint { export interface StickyLikeEndpoint {
status: Kqp1PyPRBzA; status: LikeType;
target: AddToPlaylistEndpoint; target: AddToPlaylistEndpoint;
} }
@ -1185,81 +1177,6 @@ export interface PtrackingURLClass {
headers: HeaderElement[]; headers: HeaderElement[];
} }
export interface PlayerConfig {
audioConfig: AudioConfig;
streamSelectionConfig: StreamSelectionConfig;
mediaCommonConfig: MediaCommonConfig;
webPlayerConfig: WebPlayerConfig;
}
export interface AudioConfig {
loudnessDb: number;
perceptualLoudnessDb: number;
enablePerFormatLoudness: boolean;
}
export interface MediaCommonConfig {
dynamicReadaheadConfig: DynamicReadaheadConfig;
}
export interface DynamicReadaheadConfig {
maxReadAheadMediaTimeMs: number;
minReadAheadMediaTimeMs: number;
readAheadGrowthRateMs: number;
}
export interface StreamSelectionConfig {
maxBitrate: string;
}
export interface WebPlayerConfig {
useCobaltTvosDash: boolean;
webPlayerActionsPorting: WebPlayerActionsPorting;
gatewayExperimentGroup: string;
}
export interface WebPlayerActionsPorting {
subscribeCommand: SubscribeCommand;
unsubscribeCommand: UnsubscribeCommand;
addToWatchLaterCommand: AddToWatchLaterCommand;
removeFromWatchLaterCommand: RemoveFromWatchLaterCommand;
}
export interface AddToWatchLaterCommand {
clickTrackingParams: string;
playlistEditEndpoint: AddToWatchLaterCommandPlaylistEditEndpoint;
}
export interface AddToWatchLaterCommandPlaylistEditEndpoint {
playlistId: string;
actions: PurpleAction[];
}
export interface PurpleAction {
addedVideoId: string;
action: string;
}
export interface RemoveFromWatchLaterCommand {
clickTrackingParams: string;
playlistEditEndpoint: RemoveFromWatchLaterCommandPlaylistEditEndpoint;
}
export interface RemoveFromWatchLaterCommandPlaylistEditEndpoint {
playlistId: string;
actions: FluffyAction[];
}
export interface FluffyAction {
action: string;
removedVideoId: string;
}
export interface SubscribeCommand {
clickTrackingParams: string;
subscribeEndpoint: SubscribeEndpoint;
}
export interface Storyboards { export interface Storyboards {
playerStoryboardSpecRenderer: PlayerStoryboardSpecRenderer; playerStoryboardSpecRenderer: PlayerStoryboardSpecRenderer;
} }
@ -1384,7 +1301,7 @@ export interface PlayerOverlayRendererAction {
export interface LikeButtonRenderer { export interface LikeButtonRenderer {
target: Target; target: Target;
likeStatus: Kqp1PyPRBzA; likeStatus: LikeType;
trackingParams: string; trackingParams: string;
likesAllowed: boolean; likesAllowed: boolean;
serviceEndpoints: ServiceEndpoint[]; serviceEndpoints: ServiceEndpoint[];
@ -1396,13 +1313,14 @@ export interface ServiceEndpoint {
} }
export interface ServiceEndpointLikeEndpoint { export interface ServiceEndpointLikeEndpoint {
status: Kqp1PyPRBzA; status: LikeType;
target: Target; target: Target;
likeParams?: LikeParams; likeParams?: LikeParams;
dislikeParams?: LikeParams; dislikeParams?: LikeParams;
removeLikeParams?: LikeParams; removeLikeParams?: LikeParams;
} }
// TODO: Add more
export enum LikeParams { export enum LikeParams {
Oai3D = 'OAI%3D', Oai3D = 'OAI%3D',
} }
@ -1467,16 +1385,12 @@ export interface CurrentVideoEndpoint {
export interface CurrentVideoEndpointWatchEndpoint { export interface CurrentVideoEndpointWatchEndpoint {
videoId: string; videoId: string;
playlistId: PlaylistID; playlistId: string;
index: number; index: number;
playlistSetVideoId: string; playlistSetVideoId: string;
loggingContext: LoggingContext; loggingContext: LoggingContext;
} }
export enum PlaylistID {
RDAMVMrkaNKAvksDE = 'RDAMVMrkaNKAvksDE',
}
export interface PlayerPageWatchNextResponseResponseContext { export interface PlayerPageWatchNextResponseResponseContext {
serviceTrackingParams: ServiceTrackingParam[]; serviceTrackingParams: ServiceTrackingParam[];
} }
@ -1536,6 +1450,8 @@ export interface FlagEndpoint {
flagAction: string; flagAction: string;
} }
export type RepeatMode = 'NONE' | 'ONE' | 'ALL';
export interface Queue { export interface Queue {
automixItems: unknown[]; automixItems: unknown[];
autoplay: boolean; autoplay: boolean;
@ -1553,7 +1469,7 @@ export interface Queue {
nextQueueItemId: number; nextQueueItemId: number;
playbackContentMode: string; playbackContentMode: string;
queueContextParams: string; queueContextParams: string;
repeatMode: string; repeatMode: RepeatMode;
responsiveSignals: ResponsiveSignals; responsiveSignals: ResponsiveSignals;
selectedItemIndex: number; selectedItemIndex: number;
shuffleEnabled: boolean; shuffleEnabled: boolean;
@ -1642,23 +1558,15 @@ export interface PlaylistPanelVideoRendererNavigationEndpoint {
export interface FluffyWatchEndpoint { export interface FluffyWatchEndpoint {
videoId: string; videoId: string;
playlistId?: PlaylistID; playlistId?: string;
index: number; index: number;
params: FluffyParams; params: string;
playerParams?: PlayerParams; playerParams?: string;
playlistSetVideoId?: string; playlistSetVideoId?: string;
loggingContext?: LoggingContext; loggingContext?: LoggingContext;
watchEndpointMusicSupportedConfigs: FluffyWatchEndpointMusicSupportedConfigs; watchEndpointMusicSupportedConfigs: FluffyWatchEndpointMusicSupportedConfigs;
} }
export enum FluffyParams {
OAHyAQIIAQ3D3D = 'OAHyAQIIAQ%3D%3D',
}
export enum PlayerParams {
The8Aub = '8AUB',
}
export interface FluffyWatchEndpointMusicSupportedConfigs { export interface FluffyWatchEndpointMusicSupportedConfigs {
watchEndpointMusicConfig: FluffyWatchEndpointMusicConfig; watchEndpointMusicConfig: FluffyWatchEndpointMusicConfig;
} }

40
src/types/queue.ts Normal file
View File

@ -0,0 +1,40 @@
import type { YoutubePlayer } from '@/types/youtube-player';
import type { GetState, QueueItem } from '@/types/datahost-get-state';
type StoreState = GetState;
type Store = {
dispatch: (obj: {
type: string;
payload?: {
items?: QueueItem[];
};
}) => void;
getState: () => StoreState;
replaceReducer: (param1: unknown) => unknown;
subscribe: (callback: () => void) => unknown;
}
export type QueueElement = HTMLElement & {
dispatch(obj: {
type: string;
payload?: unknown;
}): void;
queue: QueueAPI;
};
export type QueueAPI = {
getItems(): QueueItem[];
store: {
store: Store,
};
continuation?: string;
autoPlaying?: boolean;
};
export type AppElement = HTMLElement & AppAPI;
export type AppAPI = {
queue_: QueueAPI;
playerApi_: YoutubePlayer;
openToast: (message: string) => void;
// TODO: Add more
};

View File

@ -0,0 +1,7 @@
import type { QueueItem } from '@/types/datahost-get-state';
export interface QueueResponse {
items?: QueueItem[];
autoPlaying?: boolean;
continuation?: string;
}