Compare commits

...

92 Commits

Author SHA1 Message Date
8020d61715 Bump version to 3.6.0 2024-10-13 22:48:28 +09:00
cb1381bbb3 fix: apply fix from eslint 2024-10-13 22:45:11 +09:00
f42f20f770 chore(i18n): Translated using Weblate (Persian)
Currently translated at 49.1% (195 of 397 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fa/
2024-10-13 15:40:48 +02:00
9fb1dbfde0 chore(i18n): Translated using Weblate (Arabic)
Currently translated at 26.9% (107 of 397 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ar/
2024-10-13 15:40:47 +02:00
4f1ebab45d chore(i18n): Translated using Weblate (Catalan)
Currently translated at 100.0% (397 of 397 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ca/
2024-10-13 15:34:42 +02:00
95644ea513 chore(i18n): Translated using Weblate (Korean)
Currently translated at 100.0% (397 of 397 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ko/
2024-10-13 15:34:41 +02:00
2fcddc8d2d chore(i18n): Translated using Weblate (Spanish)
Currently translated at 99.7% (396 of 397 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/es/
2024-10-13 15:34:41 +02:00
6505a93645 fix(api-server): fix i18n 2024-10-13 21:56:17 +09:00
11c25efd47 fix(taskbar-mediacontrol): fix icon color
- Close #2485
2024-10-13 21:45:20 +09:00
a97bc8da5a fix(README): fix eslint path 2024-10-13 21:42:00 +09:00
782116b31b fix: pin deps version 2024-10-13 19:14:40 +09:00
708d4b5480 chore(deps): Bump pnpm/action-setup to v4 2024-10-13 19:11:03 +09:00
9ba8913da7 feat(api-server): remote control api (#1909)
Co-authored-by: JellyBrick <shlee1503@naver.com>
Co-authored-by: Angelos Bouklis <angelbouklis.official@gmail.com>
Co-authored-by: Angelos Bouklis <me@arjix.dev>
Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com>
2024-10-13 19:10:12 +09:00
d07dae2542 fix: fix pnpm-lock.yaml 2024-10-13 18:24:36 +09:00
e7f213c4dc chore(eslint): apply eslint-plugin-prettier
- Close #2438
2024-10-13 18:24:01 +09:00
4cbf6c015b chore(deps): update playwright monorepo to v1.48.0 (#2489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 18:05:01 +09:00
8acb93225b fix(synced-lyrics): fix text extract logic 2024-10-13 18:03:37 +09:00
8153955ccf fix(synced-lyrics): Fix 2 issues (#2441) 2024-10-13 18:00:03 +09:00
1de1cbac65 chore(deps): update dependency typescript to v5.6.3 (#2486)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:14:17 +09:00
825aac1dab chore(deps): update dependency electron to v32.2.0 (#2487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:14:08 +09:00
d48aa7ad39 chore(deps): update dependency del-cli to v6 (#2475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:12:05 +09:00
65ad09a02e chore(deps): update dependency typescript-eslint to v8.8.1 (#2477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:11:56 +09:00
08aae09446 fix(deps): update dependency solid-js to v1.9.2 (#2480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:11:25 +09:00
88f54a389f Revert "chore(deps): update dependency electron-builder to v25" (#2488) 2024-10-13 17:11:11 +09:00
4c23b1f970 chore(deps): update dependency electron-builder to v25 (#2406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:09:34 +09:00
a979f1c8ea fix(deps): update dependency deepmerge-ts to v7.1.3 (#2481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:09:28 +09:00
ead448ed98 chore(i18n): Translated using Weblate (Persian)
Currently translated at 51.0% (195 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fa/
2024-10-11 06:56:26 +02:00
fade340e80 chore(i18n): Translated using Weblate (Arabic)
Currently translated at 28.0% (107 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ar/
2024-10-11 06:56:25 +02:00
8cc8160f70 chore(i18n): Translated using Weblate (Persian)
Currently translated at 22.5% (86 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fa/
2024-10-10 14:39:44 +02:00
ee354ff678 chore(i18n): Added translation using Weblate (Persian) 2024-10-10 01:45:13 +02:00
ca7ccc7b3e chore(i18n): Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (382 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pt_BR/
2024-10-09 17:15:40 +02:00
f6b2766ec2 fix(deps): update dependency ts-morph to v24 (#2474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 12:42:06 +09:00
4a5f811485 fix(deps): update dependency i18next to v23.15.2 (#2471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 19:57:18 +09:00
a092da7ba5 chore(deps): update eslint monorepo to v9.12.0 (#2470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:32:49 +09:00
b587a16419 chore(deps): update dependency @stylistic/eslint-plugin-js to v2.9.0 (#2469)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:30:15 +09:00
4cf4f19ccc chore(deps): bump micromatch from 4.0.5 to 4.0.8 (#2465)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-05 15:06:59 +09:00
5b84c9efce chore(deps): bump braces from 3.0.2 to 3.0.3 (#2466)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-05 15:06:52 +09:00
98bbbfd851 fix(deps): update dependency electron-updater to v6.3.9 (#2468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:04:15 +09:00
da2dbcacc4 fix(deps): update dependency deepmerge-ts to v7.1.1 (#2467)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:02:19 +09:00
7b6235694b chore(deps): update dependency typescript-eslint to v8.8.0 (#2457)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:02:09 +09:00
4ad8e7b9dc chore(deps): update dependency @babel/runtime to v7.25.7 (#2462)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:02:01 +09:00
51ecfff86b chore(deps): update dependency rollup to v4.24.0 (#2458)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 15:00:04 +09:00
2c21a03201 chore(deps): update dependency eslint-plugin-import to v2.31.0 (#2464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-05 14:59:44 +09:00
4c2cb8dac9 chore(i18n): Translated using Weblate (Portuguese (Brazil))
Currently translated at 73.2% (280 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pt_BR/
2024-10-04 15:15:48 +02:00
9edcd2c32e chore(i18n): Translated using Weblate (Filipino)
Currently translated at 86.9% (332 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fil/
2024-10-03 02:15:41 +00:00
0829fd2167 chore(deps): update dependency rollup to v4.22.5 (#2448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-30 07:24:04 +09:00
494e58296b chore(deps): update dependency typescript-eslint to v8.7.0 (#2450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-30 07:20:57 +09:00
151da2d786 fix(deps): update dependency solid-js to v1.9.1 (#2451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-30 07:20:46 +09:00
d7f5f28091 chore(deps): update dependency vite to v5.4.8 (#2449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 15:52:06 +09:00
358fb3b084 chore(deps): update dependency discord-api-types to v0.37.101 (#2440)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 15:51:54 +09:00
af79ba266d chore(deps): update dependency esbuild to v0.24.0 (#2439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 15:51:44 +09:00
45f419f41a chore(deps): update eslint monorepo to v9.11.1 (#2442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 15:35:42 +09:00
5189d6cfee chore(deps): update dependency @types/howler to v2.2.12 (#2443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 15:35:30 +09:00
102034b58c chore(i18n): Translated using Weblate (Polish)
Currently translated at 100.0% (382 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2024-09-24 18:15:47 +02:00
50d92bc004 chore(i18n): Translated using Weblate (English)
Currently translated at 99.7% (381 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/en/
2024-09-24 18:15:46 +02:00
f2716e1dc8 chore(i18n): Translated using Weblate (Polish)
Currently translated at 100.0% (382 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2024-09-23 17:26:28 +02:00
9d436ed0a8 chore(i18n): Translated using Weblate (Polish)
Currently translated at 100.0% (382 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2024-09-23 17:26:28 +02:00
31d472e289 chore(deps): update dependency vite to v5.4.7 (#2434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:17:56 +09:00
7f9a2b3011 chore(deps): update playwright monorepo to v1.47.2 (#2436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:17:35 +09:00
f54b346dce chore(deps): update eslint monorepo to v9.11.0 (#2437)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:17:12 +09:00
dbff62bc5b fix(deps): update dependency youtubei.js to v10.5.0 (#2431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:13:18 +09:00
87f43e3237 chore(deps): update dependency rollup to v4.22.4 (#2430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:13:00 +09:00
49cdcbdcc2 chore(deps): update dependency electron to v32.1.2 (#2433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-22 08:12:50 +09:00
bcff26c85b chore(i18n): Translated using Weblate (Portuguese (Brazil))
Currently translated at 72.2% (276 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pt_BR/
2024-09-21 03:40:55 +00:00
bcfb33b4b1 chore(i18n): Translated using Weblate (Portuguese (Brazil))
Currently translated at 72.2% (276 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pt_BR/
2024-09-21 03:40:54 +00:00
b172d8e509 chore(i18n): Added translation using Weblate (Portuguese (Brazil)) 2024-09-20 04:55:25 +02:00
f10d146272 chore(i18n): Translated using Weblate (Italian)
Currently translated at 100.0% (382 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2024-09-19 23:40:54 +02:00
6105821a94 feat: ESLint Flat Config (v9 support #2229) (#2426)
* Flat config

* undo accidental formatting changes

* remove extra newline
2024-09-19 21:19:41 +09:00
7c983df6f4 chore(deps): update dependency electron to v32.1.1 2024-09-19 07:39:25 +09:00
cbbddefcf8 chore(i18n): Translated using Weblate (Ukrainian)
Currently translated at 97.3% (372 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/uk/
2024-09-18 15:41:01 +00:00
8d49c67fcb Update changelog for v3.5.3 2024-09-17 12:41:36 +00:00
c94b22b82c Bump version to 3.5.3 2024-09-17 21:32:22 +09:00
cab3cb49f0 chore(deps): update dependency electron to v32.1.0 2024-09-17 21:31:29 +09:00
e42084f008 fix(deps): update dependency @floating-ui/dom to v1.6.11 2024-09-17 21:30:11 +09:00
adca273ec3 chore(deps): update dependency typescript to v5.6.2 2024-09-17 21:28:03 +09:00
91dceb3c22 chore(deps): update playwright monorepo to v1.47.1 2024-09-17 21:27:47 +09:00
216e76f4a1 fix(music-together): fix ? operator 2024-09-17 21:09:30 +09:00
178bfa483f fix: openToast to toastService 2024-09-17 21:05:35 +09:00
10ecf5d2fe fix(src/index): fix typo 2024-09-17 20:40:23 +09:00
7099b81296 fix(src/index): ignore eslint error 2024-09-17 20:40:23 +09:00
1f15376b00 chore(deps): update dependency vite to v5.4.6 2024-09-17 20:39:32 +09:00
02b7a39753 chore(deps): update dependency eslint to v8.57.1 2024-09-17 20:38:38 +09:00
6edc84a8bd chore(deps): update dependency rollup to v4.21.3 2024-09-17 20:38:19 +09:00
11a0d39064 fix(song-info-front): fix nullable issue 2024-09-17 20:27:11 +09:00
d5a5ed35b6 fix: fix trustedHTML issue
- (Maybe) fix #2339, caused by YouTube's A/B testing
2024-09-17 20:05:22 +09:00
dbb9e95b32 fix(deps): update dependency i18next to v23.15.1 2024-09-17 18:27:27 +09:00
d4c8a4320d chore(deps): update typescript-eslint monorepo to v8.6.0 2024-09-17 18:27:05 +09:00
68d4f38e41 chore(i18n): Translated using Weblate (Hungarian)
Currently translated at 94.7% (362 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/hu/
2024-09-15 22:09:12 +00:00
2204784e89 chore(i18n): Translated using Weblate (Icelandic)
Currently translated at 92.9% (355 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/is/
2024-09-15 22:09:12 +00:00
c3b995b0a8 chore(i18n): Translated using Weblate (Hebrew)
Currently translated at 6.0% (23 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/he/
2024-09-11 22:09:24 +02:00
ed0a344077 chore(i18n): Translated using Weblate (Polish)
Currently translated at 95.2% (364 of 382 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2024-09-09 22:09:15 +00:00
199d912823 Update changelog for v3.5.2 2024-09-07 12:27:30 +00:00
125 changed files with 6106 additions and 2184 deletions

View File

@ -1 +0,0 @@
.eslintrc.js

View File

@ -1,80 +0,0 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
plugins: ['prettier', '@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
ecmaVersion: 'latest'
},
rules: {
'arrow-parens': ['error', 'always'],
'object-curly-spacing': ['error', 'always'],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
"@typescript-eslint/no-non-null-assertion": "off",
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-default-export': 'off',
'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
'import/order': [
'error',
{
'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'],
'newlines-between': 'always-and-inside-groups',
'alphabetize': {order: 'ignore', caseInsensitive: false}
}
],
'import/prefer-default-export': 'off',
'camelcase': ['error', {properties: 'never'}],
'class-methods-use-this': 'off',
'lines-around-comment': [
'error',
{
beforeBlockComment: false,
afterBlockComment: false,
beforeLineComment: false,
afterLineComment: false,
},
],
'max-len': 'off',
'no-mixed-operators': 'error',
'no-multi-spaces': ['error', {ignoreEOLComments: true}],
'no-tabs': 'error',
'no-void': 'error',
'no-empty': 'off',
'prefer-promise-reject-errors': 'off',
'quotes': ['error', 'single', {
avoidEscape: true,
allowTemplateLiterals: false,
}],
'quote-props': ['error', 'consistent'],
'semi': ['error', 'always'],
},
env: {
browser: true,
node: true,
es6: true,
},
ignorePatterns: ['dist', 'node_modules'],
root: true,
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
typescript: {},
exports: {},
},
},
};

View File

@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
run_install: false run_install: false
@ -90,7 +90,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
run_install: false run_install: false

View File

@ -4,7 +4,7 @@
[![GitHub release](https://img.shields.io/github/release/th-ch/youtube-music.svg?style=for-the-badge&logo=youtube-music)](https://github.com/th-ch/youtube-music/releases/) [![GitHub release](https://img.shields.io/github/release/th-ch/youtube-music.svg?style=for-the-badge&logo=youtube-music)](https://github.com/th-ch/youtube-music/releases/)
[![GitHub license](https://img.shields.io/github/license/th-ch/youtube-music.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/LICENSE) [![GitHub license](https://img.shields.io/github/license/th-ch/youtube-music.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
[![eslint code style](https://img.shields.io/badge/code_style-eslint-5ed9c7.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js) [![eslint code style](https://img.shields.io/badge/code_style-eslint-5ed9c7.svg?style=for-the-badge)](https://github.com/th-ch/youtube-music/blob/master/eslint.config.mjs)
[![Build status](https://img.shields.io/github/actions/workflow/status/th-ch/youtube-music/build.yml?branch=master&style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/) [![Build status](https://img.shields.io/github/actions/workflow/status/th-ch/youtube-music/build.yml?branch=master&style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![GitHub All Releases](https://img.shields.io/github/downloads/th-ch/youtube-music/total?style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/) [![GitHub All Releases](https://img.shields.io/github/downloads/th-ch/youtube-music/total?style=for-the-badge&logo=youtube-music)](https://GitHub.com/th-ch/youtube-music/releases/)
[![AUR](https://img.shields.io/aur/version/youtube-music-bin?color=blueviolet&style=for-the-badge&logo=youtube-music)](https://aur.archlinux.org/packages/youtube-music-bin) [![AUR](https://img.shields.io/aur/version/youtube-music-bin?color=blueviolet&style=for-the-badge&logo=youtube-music)](https://aur.archlinux.org/packages/youtube-music-bin)

View File

@ -2,8 +2,58 @@
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.5.3](https://github.com/th-ch/youtube-music/compare/v3.5.2...v3.5.3)
- fix: fix `trustedHTML` issue [`#2339`](https://github.com/th-ch/youtube-music/issues/2339)
- chore(deps): update dependency rollup to v4.21.3 [`6edc84a`](https://github.com/th-ch/youtube-music/commit/6edc84a8bd6c7e009041117ba0d2004783eb3a47)
- chore(deps): update typescript-eslint monorepo to v8.6.0 [`d4c8a43`](https://github.com/th-ch/youtube-music/commit/d4c8a4320d733f7bddc4dcd1de93644790e71d66)
- chore(deps): update dependency eslint to v8.57.1 [`02b7a39`](https://github.com/th-ch/youtube-music/commit/02b7a39753528cfd8c0d107d6d2ec6ef78c5afe7)
#### [v3.5.2](https://github.com/th-ch/youtube-music/compare/v3.5.1...v3.5.2)
> 7 September 2024
- chore(deps): update typescript-eslint monorepo to v8.4.0 [`#2401`](https://github.com/th-ch/youtube-music/pull/2401)
- chore(deps): update dependency @total-typescript/ts-reset to v0.6.1 [`#2396`](https://github.com/th-ch/youtube-music/pull/2396)
- chore(deps): update dependency electron to v31.5.0 [`#2397`](https://github.com/th-ch/youtube-music/pull/2397)
- chore(deps): update dependency eslint-import-resolver-typescript to v3.6.3 [`#2376`](https://github.com/th-ch/youtube-music/pull/2376)
- chore(deps): update dependency discord-api-types to v0.37.100 [`#2394`](https://github.com/th-ch/youtube-music/pull/2394)
- fix(deps): update dependency electron-updater to v6.3.4 [`#2395`](https://github.com/th-ch/youtube-music/pull/2395)
- chore(deps): update dependency @babel/runtime to v7.25.6 [`#2388`](https://github.com/th-ch/youtube-music/pull/2388)
- chore(deps): update dependency vite-plugin-inspect to v0.8.7 [`#2389`](https://github.com/th-ch/youtube-music/pull/2389)
- chore(deps): update dependency discord-api-types to v0.37.99 [`#2374`](https://github.com/th-ch/youtube-music/pull/2374)
- chore(deps): update dependency vite to v5.4.3 [`#2377`](https://github.com/th-ch/youtube-music/pull/2377)
- fix: incorrect regex when splitting artistName [`#2378`](https://github.com/th-ch/youtube-music/pull/2378)
- chore(deps): update dependency @babel/runtime to v7.25.4 [`#2373`](https://github.com/th-ch/youtube-music/pull/2373)
- synced-lyrics: make the lyrics search more reliable [`#2343`](https://github.com/th-ch/youtube-music/pull/2343)
- fix(deps): update dependency solid-js to v1.8.22 [`#2354`](https://github.com/th-ch/youtube-music/pull/2354)
- chore(deps): update typescript-eslint monorepo to v8.3.0 [`#2350`](https://github.com/th-ch/youtube-music/pull/2350)
- fix(deps): update dependency electron-debug to v4.0.1 [`#2349`](https://github.com/th-ch/youtube-music/pull/2349)
- chore(deps): update dependency electron to v31.4.0 [`#2356`](https://github.com/th-ch/youtube-music/pull/2356)
- fix: hide native-controls on linux when in-app-menu is used [`#2366`](https://github.com/th-ch/youtube-music/pull/2366)
- fix: detect the upgrade btn using the icon [`#2364`](https://github.com/th-ch/youtube-music/pull/2364)
- fix: exclude build-id files from rpm [`#2361`](https://github.com/th-ch/youtube-music/pull/2361)
- fix(deps): update dependency i18next to v23.12.3 [`#2352`](https://github.com/th-ch/youtube-music/pull/2352)
- fix(deps): update dependency @floating-ui/dom to v1.6.10 [`#2340`](https://github.com/th-ch/youtube-music/pull/2340)
- fix(deps): update dependency electron-updater to v6.3.3 [`#2347`](https://github.com/th-ch/youtube-music/pull/2347)
- fix(deps): update dependency solid-js to v1.8.20 [`#2345`](https://github.com/th-ch/youtube-music/pull/2345)
- chore(deps): update dependency vite to v5.4.0 [`#2342`](https://github.com/th-ch/youtube-music/pull/2342)
- chore(deps): update typescript-eslint monorepo to v8.0.1 [`#2335`](https://github.com/th-ch/youtube-music/pull/2335)
- fix(deps): update dependency @floating-ui/dom to v1.6.9 [`#2337`](https://github.com/th-ch/youtube-music/pull/2337)
- chore(deps): update playwright monorepo to v1.46.0 [`#2336`](https://github.com/th-ch/youtube-music/pull/2336)
- chore(README): Translation README to Russian and adding Synced Lyrics to main README [`#2338`](https://github.com/th-ch/youtube-music/pull/2338)
- chore(deps): update dependency rollup to v4.20.0 [`#2326`](https://github.com/th-ch/youtube-music/pull/2326)
- fix(synced-lyric): fix timestamp [`#2323`](https://github.com/th-ch/youtube-music/issues/2323) [`#2379`](https://github.com/th-ch/youtube-music/issues/2379)
- Revert "fix(MPRIS): Prevents player to start with invalid MPRIS interface (#1996)" [`#2225`](https://github.com/th-ch/youtube-music/issues/2225)
- fix(adblocker/inplayer): fix Response.prototype.json [`#2310`](https://github.com/th-ch/youtube-music/issues/2310)
- chore(deps): update dependency eslint-plugin-import to v2.30.0 [`f48e46d`](https://github.com/th-ch/youtube-music/commit/f48e46d29cf09c76c5172fd56d2d0f705616e4e3)
- Revert "chore(deps): update dependency electron-builder to v25" [`089eff3`](https://github.com/th-ch/youtube-music/commit/089eff3152903c8b55ad3e5571b944062a647e27)
- chore(deps): update dependency electron-builder to v25 [`fe4c89c`](https://github.com/th-ch/youtube-music/commit/fe4c89c349bb9f4f54d95c2018943095ccfdab0c)
#### [v3.5.1](https://github.com/th-ch/youtube-music/compare/v3.5.0...v3.5.1) #### [v3.5.1](https://github.com/th-ch/youtube-music/compare/v3.5.0...v3.5.1)
> 1 August 2024
- fix(deps): update dependency youtubei.js to v10.3.0 [`#2306`](https://github.com/th-ch/youtube-music/pull/2306) - fix(deps): update dependency youtubei.js to v10.3.0 [`#2306`](https://github.com/th-ch/youtube-music/pull/2306)
- fix: Window gets stuck offscreen in some instances [`#2303`](https://github.com/th-ch/youtube-music/pull/2303) - fix: Window gets stuck offscreen in some instances [`#2303`](https://github.com/th-ch/youtube-music/pull/2303)
- fix: Incorrect window size on multi-monitor scaled displays [`#2302`](https://github.com/th-ch/youtube-music/pull/2302) - fix: Incorrect window size on multi-monitor scaled displays [`#2302`](https://github.com/th-ch/youtube-music/pull/2302)

80
eslint.config.mjs Normal file
View File

@ -0,0 +1,80 @@
//@ts-check
import eslint from '@eslint/js';
import prettier from 'eslint-plugin-prettier/recommended';
import stylistic from '@stylistic/eslint-plugin-js';
import tsEslint from 'typescript-eslint';
import * as importPlugin from 'eslint-plugin-import';
export default tsEslint.config(
eslint.configs.recommended,
tsEslint.configs.eslintRecommended,
...tsEslint.configs.recommendedTypeChecked,
prettier,
{ ignores: ['dist', 'node_modules', '*.config.*js', '*.test.*js'] },
{
plugins: {
stylistic,
importPlugin
},
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
project: true,
sourceType: 'module',
ecmaVersion: 'latest'
}
},
rules: {
'stylistic/arrow-parens': ['error', 'always'],
'stylistic/object-curly-spacing': ['error', 'always'],
'prettier/prettier': ['error', { singleQuote: true, semi: true, tabWidth: 2, trailingComma: 'all', quoteProps: 'preserve' }],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-non-null-assertion': 'off',
'importPlugin/first': 'error',
'importPlugin/newline-after-import': 'off',
'importPlugin/no-default-export': 'off',
'importPlugin/no-duplicates': 'error',
'importPlugin/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
'importPlugin/order': ['error', {
'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'],
'newlines-between': 'always-and-inside-groups',
'alphabetize': { order: 'ignore', caseInsensitive: false }
}],
'importPlugin/prefer-default-export': 'off',
'camelcase': ['error', { properties: 'never' }],
'class-methods-use-this': 'off',
'stylistic/lines-around-comment': ['error', {
beforeBlockComment: false,
afterBlockComment: false,
beforeLineComment: false,
afterLineComment: false,
}],
'stylistic/max-len': 'off',
'stylistic/no-mixed-operators': 'warn', // prettier does not support no-mixed-operators
'stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }],
'stylistic/no-tabs': 'error',
'no-void': 'error',
'no-empty': 'off',
'prefer-promise-reject-errors': 'off',
'stylistic/quotes': ['error', 'single', {
avoidEscape: true,
allowTemplateLiterals: false,
}],
'stylistic/quote-props': ['error', 'consistent'],
'stylistic/semi': ['error', 'always'],
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
typescript: {},
exports: {},
},
},
},
);

View File

@ -1,7 +1,7 @@
{ {
"name": "youtube-music", "name": "youtube-music",
"productName": "YouTube Music", "productName": "YouTube Music",
"version": "3.5.2", "version": "3.6.0",
"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",
@ -149,7 +149,7 @@
"xml2js": "0.6.2", "xml2js": "0.6.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"@electron/universal": "2.0.1", "@electron/universal": "2.0.1",
"@babel/runtime": "7.25.6" "@babel/runtime": "7.25.7"
}, },
"patchedDependencies": { "patchedDependencies": {
"vudio@2.1.1": "patches/vudio@2.1.1.patch", "vudio@2.1.1": "patches/vudio@2.1.1.patch",
@ -163,11 +163,16 @@
"@electron/remote": "2.1.2", "@electron/remote": "2.1.2",
"@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0", "@ffmpeg.wasm/main": "0.12.0",
"@floating-ui/dom": "1.6.10", "@floating-ui/dom": "1.6.11",
"@foobar404/wave": "2.0.5", "@foobar404/wave": "2.0.5",
"@hono/node-server": "1.13.2",
"@hono/swagger-ui": "0.4.1",
"@hono/zod-openapi": "0.16.4",
"@hono/zod-validator": "0.4.1",
"@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/electron-better-web-request": "1.0.4",
"@jellybrick/mpris-service": "2.1.4", "@jellybrick/mpris-service": "2.1.4",
"@skyra/jaro-winkler": "^1.1.1", "@jimp/plugin-invert": "0.22.12",
"@skyra/jaro-winkler": "1.1.1",
"@xhayper/discord-rpc": "1.2.0", "@xhayper/discord-rpc": "1.2.0",
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
"butterchurn": "3.0.0-beta.4", "butterchurn": "3.0.0-beta.4",
@ -176,19 +181,21 @@
"conf": "13.0.1", "conf": "13.0.1",
"custom-electron-prompt": "1.5.8", "custom-electron-prompt": "1.5.8",
"dbus-next": "0.10.2", "dbus-next": "0.10.2",
"deepmerge-ts": "7.1.0", "deepmerge-ts": "7.1.3",
"electron-debug": "4.0.1", "electron-debug": "4.0.1",
"electron-is": "3.0.0", "electron-is": "3.0.0",
"electron-localshortcut": "3.2.1", "electron-localshortcut": "3.2.1",
"electron-store": "10.0.0", "electron-store": "10.0.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-updater": "6.3.4", "electron-updater": "6.3.9",
"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",
"hono": "4.6.4",
"howler": "2.2.4", "howler": "2.2.4",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"i18next": "23.14.0", "i18next": "23.15.2",
"jimp": "1.6.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.13", "node-html-parser": "6.1.13",
@ -198,46 +205,51 @@
"serve": "14.2.3", "serve": "14.2.3",
"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.22", "solid-js": "1.9.2",
"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": "23.0.0", "ts-morph": "24.0.0",
"vudio": "2.1.1", "vudio": "2.1.1",
"x11": "2.3.0", "x11": "2.3.0",
"youtubei.js": "10.4.0" "youtubei.js": "10.5.0",
"zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.47.0", "@eslint/js": "9.12.0",
"@playwright/test": "1.48.0",
"@stylistic/eslint-plugin-js": "2.9.0",
"@total-typescript/ts-reset": "0.6.1", "@total-typescript/ts-reset": "0.6.1",
"@types/color": "3.0.6", "@types/color": "3.0.6",
"@types/electron-localshortcut": "3.1.3", "@types/electron-localshortcut": "3.1.3",
"@types/howler": "2.2.11", "@types/eslint__js": "8.42.3",
"@types/howler": "2.2.12",
"@types/html-to-text": "9.0.4", "@types/html-to-text": "9.0.4",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@typescript-eslint/eslint-plugin": "8.4.0", "@types/trusted-types": "2.0.7",
"@typescript-eslint/parser": "8.4.0",
"bufferutil": "4.0.8", "bufferutil": "4.0.8",
"builtin-modules": "4.0.0", "builtin-modules": "4.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"del-cli": "5.1.0", "del-cli": "6.0.0",
"discord-api-types": "0.37.100", "discord-api-types": "0.37.101",
"electron": "32.0.2", "electron": "32.2.0",
"electron-builder": "24.13.3", "electron-builder": "24.13.3",
"electron-devtools-installer": "3.2.0", "electron-devtools-installer": "3.2.0",
"electron-vite": "2.3.0", "electron-vite": "2.3.0",
"esbuild": "0.23.1", "esbuild": "0.24.0",
"eslint": "8.57.0", "eslint": "9.12.0",
"eslint-config-prettier": "9.1.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.3", "eslint-import-resolver-typescript": "3.6.3",
"eslint-plugin-import": "2.30.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "5.2.1", "eslint-plugin-prettier": "5.2.1",
"glob": "11.0.0", "glob": "11.0.0",
"node-gyp": "10.2.0", "node-gyp": "10.2.0",
"playwright": "1.47.0", "playwright": "1.48.0",
"rollup": "4.21.2", "rollup": "4.24.0",
"typescript": "5.5.4", "typescript": "5.6.3",
"typescript-eslint": "8.8.1",
"utf-8-validate": "6.0.4", "utf-8-validate": "6.0.4",
"vite": "5.4.3", "vite": "5.4.8",
"vite-plugin-inspect": "0.8.7", "vite-plugin-inspect": "0.8.7",
"vite-plugin-resolve": "2.5.2", "vite-plugin-resolve": "2.5.2",
"vite-plugin-solid": "2.10.2", "vite-plugin-solid": "2.10.2",

2411
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -64,29 +64,29 @@ declare module 'custom-electron-prompt' {
export type PromptOptions<T extends string> = T extends 'input' export type PromptOptions<T extends string> = T extends 'input'
? InputPromptOptions ? InputPromptOptions
: T extends 'select' : T extends 'select'
? SelectPromptOptions ? SelectPromptOptions
: T extends 'counter' : T extends 'counter'
? CounterPromptOptions ? CounterPromptOptions
: T extends 'keybind' : T extends 'keybind'
? KeybindPromptOptions ? KeybindPromptOptions
: T extends 'multiInput' : T extends 'multiInput'
? MultiInputPromptOptions ? MultiInputPromptOptions
: never; : never;
type PromptResult<T extends string> = T extends 'input' type PromptResult<T extends string> = T extends 'input'
? string ? string
: T extends 'select' : T extends 'select'
? string ? string
: T extends 'counter' : T extends 'counter'
? number ? number
: T extends 'keybind' : T extends 'keybind'
? { ? {
value: string; value: string;
accelerator: string; accelerator: string;
}[] }[]
: T extends 'multiInput' : T extends 'multiInput'
? string[] ? string[]
: never; : never;
const prompt: <T extends Type>( const prompt: <T extends Type>(
options?: PromptOptions<T> & { type: T }, options?: PromptOptions<T> & { type: T },

View File

@ -14,9 +14,9 @@
} }
}, },
"language": { "language": {
"code": "إنجليزي", "code": "ar",
"local-name": "الإنجليزي", "local-name": "العربية",
"name": "الإنجليزية" "name": "Arabic"
}, },
"main": { "main": {
"console": { "console": {
@ -194,7 +194,21 @@
} }
}, },
"tray": { "tray": {
"next": "التالي" "next": "التالي",
"previous": "السابق",
"quit": "خروج",
"restart": "إعادة تشغيل التطبيق",
"show": "عرض النافدة",
"tooltip": {
"default": "يوتيوب اغاني",
"with-song-info": "يوتيوب أغاني: {{الفنان}}-{{العنوان}}"
}
}
},
"plugins": {
"adblocker": {
"description": "حجب جميع الإعلانات والمسارات خارج الصندوق",
"name": "حاجب الإعلانات"
} }
} }
} }

View File

@ -279,6 +279,49 @@
}, },
"name": "Mode ambient" "name": "Mode ambient"
}, },
"api-server": {
"description": "Afegeix un servidor API per controlar el reproductor",
"dialog": {
"request": {
"buttons": {
"allow": "Permet",
"deny": "Denegar"
},
"message": "Permetre que {{ID}} ({{origin}}) accedeixi a l'API?",
"title": "Petició d'autorització API"
}
},
"menu": {
"auth-strategy": {
"label": "Estratègia d'autorització",
"submenu": {
"auth-at-first": {
"label": "Autoritza a la primera petició"
},
"none": {
"label": "Sense autorització"
}
}
},
"hostname": {
"label": "Nom del host"
},
"port": {
"label": "Port"
}
},
"name": "Servidor API [Beta]",
"prompt": {
"hostname": {
"label": "Introdueix el nom del host (per exemple 0.0.0.0) pel servidor API:",
"title": "Nom del host"
},
"port": {
"label": "Introdueix el port pel servidor API:",
"title": "Port"
}
}
},
"audio-compressor": { "audio-compressor": {
"description": "Aplica compressió a l'àudio (baixa el volum de les parts més sorolloses de la senyal d'àudio i puja el volum de les parts més fluixes)", "description": "Aplica compressió a l'àudio (baixa el volum de les parts més sorolloses de la senyal d'àudio i puja el volum de les parts més fluixes)",
"name": "Compressió d'àudio" "name": "Compressió d'àudio"

View File

@ -158,18 +158,18 @@
}, },
"remove-upgrade-button": "Remove upgrade button", "remove-upgrade-button": "Remove upgrade button",
"theme": { "theme": {
"dialog": {
"button": {
"cancel": "Cancel",
"remove": "Remove"
},
"remove-theme": "Are you sure you want to remove the custom theme?",
"remove-theme-message": "This will remove the custom theme"
},
"label": "Theme", "label": "Theme",
"submenu": { "submenu": {
"import-css-file": "Import custom CSS file", "import-css-file": "Import custom CSS file",
"no-theme": "No theme" "no-theme": "No theme"
},
"dialog": {
"remove-theme": "Are you sure you want to remove the custom theme?",
"remove-theme-message": "This will remove the custom theme",
"button": {
"cancel": "Cancel",
"remove": "Remove"
}
} }
} }
} }
@ -207,6 +207,10 @@
} }
}, },
"plugins": { "plugins": {
"ad-speedup": {
"description": "If an ad play it mutes the audio and sets playback speed to 16x",
"name": "Ad Speedup"
},
"adblocker": { "adblocker": {
"description": "Block all ads and tracking out of the box", "description": "Block all ads and tracking out of the box",
"menu": { "menu": {
@ -214,17 +218,12 @@
}, },
"name": "Ad Blocker" "name": "Ad Blocker"
}, },
"ad-speedup": {
"name": "Ad Speedup",
"description": "If an ad play it mutes the audio and sets playback speed to 16x"
},
"album-actions": { "album-actions": {
"description": "Adds Undislike, Dislike, Like, and Unlike buttons to apply this to all songs in a playlist or album", "description": "Adds Undislike, Dislike, Like, and Unlike buttons to apply this to all songs in a playlist or album",
"name": "Album Actions" "name": "Album Actions"
}, },
"album-color-theme": { "album-color-theme": {
"description": "Applies a dynamic theme and visual effects based on the album color palette", "description": "Applies a dynamic theme and visual effects based on the album color palette",
"name": "Album Color Theme",
"menu": { "menu": {
"color-mix-ratio": { "color-mix-ratio": {
"label": "Color mix ratio", "label": "Color mix ratio",
@ -232,7 +231,8 @@
"percent": "{{ratio}}%" "percent": "{{ratio}}%"
} }
} }
} },
"name": "Album Color Theme"
}, },
"ambient-mode": { "ambient-mode": {
"description": "Applies a lighting effect by casting gentle colors from the video, into your screens background", "description": "Applies a lighting effect by casting gentle colors from the video, into your screens background",
@ -279,6 +279,49 @@
}, },
"name": "Ambient Mode" "name": "Ambient Mode"
}, },
"api-server": {
"description": "Adds an API server to control the player",
"name": "API Server [Beta]",
"dialog": {
"request": {
"title": "API authorization request",
"message": "Allow {{ID}} ({{origin}}) to access the API?",
"buttons": {
"allow": "Allow",
"deny": "Deny"
}
}
},
"menu": {
"hostname": {
"label": "Hostname"
},
"port": {
"label": "Port"
},
"auth-strategy": {
"label": "Authorization strategy",
"submenu": {
"auth-at-first": {
"label": "Authorize at first request"
},
"none": {
"label": "No authorization"
}
}
}
},
"prompt": {
"hostname": {
"title": "Hostname",
"label": "Enter the hostname (like 0.0.0.0) for the API server:"
},
"port": {
"title": "Port",
"label": "Enter the port for the API server:"
}
}
},
"audio-compressor": { "audio-compressor": {
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)", "description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
"name": "Audio Compressor" "name": "Audio Compressor"
@ -414,24 +457,24 @@
"description": "Downloads MP3 / source audio directly from the interface", "description": "Downloads MP3 / source audio directly from the interface",
"menu": { "menu": {
"choose-download-folder": "Choose download folder", "choose-download-folder": "Choose download folder",
"download-playlist": "Download playlist",
"presets": "Presets",
"skip-existing": "Skip existing files",
"download-finish-settings": { "download-finish-settings": {
"label": "Download on finish", "label": "Download on finish",
"prompt": {
"last-percent": "After x percent",
"last-seconds": "Last x seconds",
"title": "Configure when to download"
},
"submenu": { "submenu": {
"advanced": "Advanced",
"enabled": "Enabled", "enabled": "Enabled",
"mode": "Time mode", "mode": "Time mode",
"seconds": "Seconds",
"percent": "Percent", "percent": "Percent",
"advanced": "Advanced" "seconds": "Seconds"
},
"prompt": {
"title": "Configure when to download",
"last-seconds": "Last x seconds",
"last-percent": "After x percent"
} }
} },
"download-playlist": "Download playlist",
"presets": "Presets",
"skip-existing": "Skip existing files"
}, },
"name": "Downloader", "name": "Downloader",
"renderer": { "renderer": {
@ -609,19 +652,19 @@
"dialog": { "dialog": {
"lastfm": { "lastfm": {
"auth-failed": { "auth-failed": {
"title": "Authentication Failed", "message": "Failed to authenticate with Last.fm\nHide the popup until the next restart.",
"message": "Failed to authenticate with Last.fm\nHide the popup until the next restart." "title": "Authentication Failed"
} }
} }
}, },
"menu": { "menu": {
"scrobble-other-media": "Scrobble other media",
"lastfm": { "lastfm": {
"api-settings": "Last.fm API Settings" "api-settings": "Last.fm API Settings"
}, },
"listenbrainz": { "listenbrainz": {
"token": "Enter ListenBrainz user token" "token": "Enter ListenBrainz user token"
} },
"scrobble-other-media": "Scrobble other media"
}, },
"name": "Scrobbler", "name": "Scrobbler",
"prompt": { "prompt": {
@ -670,55 +713,55 @@
}, },
"synced-lyrics": { "synced-lyrics": {
"description": "Provides synced lyrics to songs, using providers like LRClib.", "description": "Provides synced lyrics to songs, using providers like LRClib.",
"name": "Synced Lyrics",
"errors": { "errors": {
"fetch": "⚠️ - An error occurred while fetching the lyrics. Please try again later.", "fetch": "⚠️ - An error occurred while fetching the lyrics. Please try again later.",
"not-found": "⚠️ - No lyrics found for this song." "not-found": "⚠️ - No lyrics found for this song."
}, },
"warnings": {
"instrumental": "⚠️ - This is an instrumental song",
"inexact": "⚠️ - The lyrics for this song may not be exact",
"duration-mismatch": "⚠️ - The lyrics may be out of sync due to a duration mismatch."
},
"refetch-btn": {
"normal": "Refetch lyrics",
"fetching": "Fetching..."
},
"menu": { "menu": {
"precise-timing": { "default-text-string": {
"label": "Make the lyrics perfectly synced", "label": "Default character between lyrics",
"tooltip": "Calculate to the milisecond the display of the next line (can have a small impact on performance)" "tooltip": "Choose the default character to use for the gap between lyrics"
}, },
"line-effect": { "line-effect": {
"label": "Line effect", "label": "Line effect",
"tooltip": "Choose the effect to apply to the current line",
"submenu": { "submenu": {
"scale": { "focus": {
"label": "Scale", "label": "Focus",
"tooltip": "Scale the current line" "tooltip": "Make only the current line white"
}, },
"offset": { "offset": {
"label": "Offset", "label": "Offset",
"tooltip": "Offset on the right the current line" "tooltip": "Offset on the right the current line"
}, },
"focus": { "scale": {
"label": "Focus", "label": "Scale",
"tooltip": "Make only the current line white" "tooltip": "Scale the current line"
} }
} },
"tooltip": "Choose the effect to apply to the current line"
}, },
"default-text-string": { "precise-timing": {
"label": "Default character between lyrics", "label": "Make the lyrics perfectly synced",
"tooltip": "Choose the default character to use for the gap between lyrics" "tooltip": "Calculate to the milisecond the display of the next line (can have a small impact on performance)"
},
"show-time-codes": {
"label": "Show time codes",
"tooltip": "Show the time codes next to the lyrics"
}, },
"show-lyrics-even-if-inexact": { "show-lyrics-even-if-inexact": {
"label": "Show lyrics even if inexact", "label": "Show lyrics even if inexact",
"tooltip": "If the song is not found, the plugin tries again with a different search query.\nThe result from the second attempt may not be exact." "tooltip": "If the song is not found, the plugin tries again with a different search query.\nThe result from the second attempt may not be exact."
},
"show-time-codes": {
"label": "Show time codes",
"tooltip": "Show the time codes next to the lyrics"
} }
},
"name": "Synced Lyrics",
"refetch-btn": {
"fetching": "Fetching...",
"normal": "Refetch lyrics"
},
"warnings": {
"duration-mismatch": "⚠️ - The lyrics may be out of sync due to a duration mismatch.",
"inexact": "⚠️ - The lyrics for this song may not be exact",
"instrumental": "⚠️ - This is an instrumental song"
} }
}, },
"taskbar-mediacontrol": { "taskbar-mediacontrol": {

View File

@ -279,6 +279,48 @@
}, },
"name": "Modo ambiente" "name": "Modo ambiente"
}, },
"api-server": {
"description": "Añade un servidor API para controlar el reproductor",
"dialog": {
"request": {
"buttons": {
"allow": "Permitir",
"deny": "Denegar"
},
"message": "¿Permitir {{ID}} ({{origin}}) acceder a la API?",
"title": "Petición de autorización API"
}
},
"menu": {
"auth-strategy": {
"submenu": {
"auth-at-first": {
"label": "Autorizar la primera solicitud"
},
"none": {
"label": "Sin autorización"
}
}
},
"hostname": {
"label": "Nombre del host"
},
"port": {
"label": "Puerto"
}
},
"name": "Servidor API [Beta]",
"prompt": {
"hostname": {
"label": "Introduzca el nombre de host (como 0.0.0.0) para el servidor API:",
"title": "Nombre de host"
},
"port": {
"label": "Introduzca el puerto para el servidor API:",
"title": "Puerto"
}
}
},
"audio-compressor": { "audio-compressor": {
"description": "Aplicar compresión al audio (reduce la diferencia entre las partes más fuertes y más suaves de una pista para que tenga un nivel más consistente)", "description": "Aplicar compresión al audio (reduce la diferencia entre las partes más fuertes y más suaves de una pista para que tenga un nivel más consistente)",
"name": "Compresor de audio" "name": "Compresor de audio"

397
src/i18n/resources/fa.json Normal file
View File

@ -0,0 +1,397 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "اجرای افزونه {{pluginName}}::{{contextName}} با خطا مواجه شد",
"executed-at-ms": "افزونه {{pluginName}}::{{contextName}} در {{ms}} میلی‌ثانیه اجرا شد",
"initialize-failed": "افزونه \"{{pluginName}}\" با خطا در حین مقداردهی اولیه مواجه شد",
"load-all": "در حال بارگذاری تمامی افزونه‌ها",
"load-failed": "افزونه \"{{pluginName}}\" بارگیری نشد",
"loaded": "افزونه \"{{pluginName}}\" بارگیری شد",
"unload-failed": "افزونه \"{{pluginName}}\" بارگذاری نشد",
"unloaded": "افزونه \"{{pluginName}}\" بارگذاری شد"
}
}
},
"language": {
"code": "fa",
"local-name": "فارسی",
"name": "Persian"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "بارگذاری کامل شد. DevTools باز شد"
},
"i18n": {
"loaded": "i18n بارگذاری شد"
},
"second-instance": {
"receive-command": "دریافت فرمان از طریق پروتکل: \"{{command}}\""
},
"theme": {
"css-file-not-found": "فایل CSS \"{{cssFile}}\" وجود ندارد، نادیده گرفته شد"
},
"unresponsive": {
"details": "خطای عدم پاسخگویی!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "پاکسازی حافظه کش برنامه"
},
"window": {
"tried-to-render-offscreen": "پنجره تلاش کرد خارج از صفحه نمایش داده شود، اندازه پنجره={{windowSize}}، اندازه نمایشگر={{displaySize}}، موقعیت={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "منو مخفی است، از 'Alt' برای نمایش آن استفاده کنید (یا 'Escape' اگر از منوی داخل برنامه استفاده می‌کنید)",
"message": "پنهان‌سازی منو فعال است",
"title": "پنهان کردن منو فعال شد"
},
"need-to-restart": {
"buttons": {
"later": "بعداً",
"restart-now": "هم‌اکنون راه‌اندازی مجدد کنید"
},
"detail": "افزونه \"{{pluginName}}\" برای اعمال تغییرات نیاز به راه‌اندازی مجدد دارد",
"message": "\"{{pluginName}}\" نیاز به راه‌اندازی مجدد دارد",
"title": "نیاز به راه‌اندازی مجدد"
},
"unresponsive": {
"buttons": {
"quit": "خروج",
"relaunch": "راه‌اندازی مجدد",
"wait": "منتظر بمانید"
},
"detail": "از بابت این مشکل متأسفیم! لطفاً انتخاب کنید که چه کاری انجام دهید:",
"message": "برنامه پاسخی نمی‌دهد",
"title": "پنجره بدون پاسخ"
},
"update-available": {
"buttons": {
"disable": "غیرفعال کردن به‌روزرسانی‌ها",
"download": "دانلود",
"ok": "تأیید"
},
"detail": "نسخه جدیدی در دسترس است و می‌توان آن را از {{downloadLink}} دانلود کرد",
"message": "نسخه جدیدی در دسترس است",
"title": "به‌روزرسانی موجود است"
}
},
"menu": {
"about": "درباره",
"navigation": {
"label": "ناوبری",
"submenu": {
"copy-current-url": "کپی کردن URL فعلی",
"go-back": "بازگشت",
"go-forward": "حرکت به جلو",
"quit": "خروجی",
"restart": "راه‌اندازی مجدد برنامه"
}
},
"options": {
"label": "گزینه‌ها",
"submenu": {
"advanced-options": {
"label": "گزینه‌های پیشرفته",
"submenu": {
"auto-reset-app-cache": "ریست کردن حافظه کش برنامه هنگام شروع",
"disable-hardware-acceleration": "غیرفعال کردن شتاب سخت‌افزاری",
"edit-config-json": "ویرایش config.json",
"override-user-agent": "تغییر User-Agent",
"restart-on-config-changes": "راه‌اندازی مجدد در صورت تغییرات در پیکربندی",
"set-proxy": {
"label": "تنظیم پراکسی",
"prompt": {
"label": "آدرس پراکسی را وارد کنید: (برای غیرفعال کردن، خالی بگذارید)",
"placeholder": "مثال: SOCKS5://127.0.0.1:9999",
"title": "تنظیم پراکسی"
}
},
"toggle-dev-tools": "باز کردن DevTools"
}
},
"always-on-top": "همیشه در بالا",
"auto-update": "به‌روزرسانی خودکار",
"hide-menu": {
"dialog": {
"message": "منو در اجرای بعدی مخفی خواهد بود، از [Alt] برای نمایش استفاده کنید (یا [`] اگر از منوی داخل برنامه استفاده می‌کنید)",
"title": "پنهان‌سازی منو فعال شد"
},
"label": "پنهان کردن منو"
},
"language": {
"dialog": {
"message": "زبان پس از راه‌اندازی مجدد تغییر خواهد کرد",
"title": "زبان تغییر کرد"
},
"label": "زبان",
"submenu": {
"to-help-translate": "می‌خواهید به ترجمه کمک کنید؟ اینجا کلیک کنید"
}
},
"resume-on-start": "ادامه آخرین آهنگ هنگام شروع برنامه",
"single-instance-lock": "قفل تنها یک نمونه",
"start-at-login": "شروع هنگام ورود",
"starting-page": {
"label": "صفحه شروع",
"unset": "لغو تنظیم"
},
"tray": {
"label": "نوار",
"submenu": {
"disabled": "غیرفعال",
"enabled-and-hide-app": "فعال و پنهان کردن برنامه",
"enabled-and-show-app": "فعال و نمایش برنامه",
"play-pause-on-click": "پخش/توقف با کلیک"
}
},
"visual-tweaks": {
"label": "تغییرات ظاهری",
"submenu": {
"like-buttons": {
"default": "پیش‌فرض",
"force-show": "اجبار به نمایش",
"hide": "پنهان کردن",
"label": "دکمه‌های پسندیدن"
},
"remove-upgrade-button": "حذف دکمه ارتقا",
"theme": {
"dialog": {
"button": {
"cancel": "لغو",
"remove": "حذف"
},
"remove-theme": "آیا مطمئن هستید که می‌خواهید تم سفارشی را حذف کنید؟",
"remove-theme-message": "این کار تم سفارشی را حذف خواهد کرد"
},
"label": "تم",
"submenu": {
"import-css-file": "وارد کردن فایل CSS سفارشی",
"no-theme": "بدون تم"
}
}
}
}
}
},
"plugins": {
"enabled": "فعال",
"label": "افزونه‌ها",
"new": "جدید"
},
"view": {
"label": "مشاهده",
"submenu": {
"force-reload": "اجبار به بارگذاری مجدد",
"reload": "بارگذاری مجدد",
"reset-zoom": "اندازه واقعی",
"toggle-fullscreen": "تغییر به تمام‌صفحه",
"zoom-in": "بزرگنمایی",
"zoom-out": "کوچکنمایی"
}
}
},
"tray": {
"next": "بعدی",
"play-pause": "پخش/توقف",
"previous": "قبلی",
"quit": "خروجی",
"restart": "راه‌اندازی مجدد برنامه",
"show": "نمایش پنجره",
"tooltip": {
"default": "یوتیوب موسیقی",
"with-song-info": "یوتیوب موسیقی: {{artist}} - {{title}}"
}
}
},
"plugins": {
"ad-speedup": {
"description": "اگر تبلیغ پخش شود، صدا را بی‌صدا کرده و سرعت پخش را به 16 برابر افزایش می‌دهد",
"name": "سرعت‌دهی به تبلیغ"
},
"adblocker": {
"description": "مسدود کردن تمامی تبلیغات و ردیابی‌ها از ابتدا",
"menu": {
"blocker": "مسدودکننده"
},
"name": "مسدودکننده تبلیغات"
},
"album-actions": {
"description": "افزودن دکمه‌های \"برگرفتن ناپسند\"، \"ناپسند\"، \"پسند\"، و \"حذف پسند\" برای اعمال آنها روی همه آهنگ‌ها در یک فهرست پخش یا آلبوم",
"name": "عملیات آلبوم"
},
"album-color-theme": {
"description": "اعمال یک تم پویا و جلوه‌های بصری بر اساس پالت رنگ آلبوم",
"menu": {
"color-mix-ratio": {
"label": "نسبت ترکیب رنگ"
}
},
"name": "تم رنگ آلبوم"
},
"ambient-mode": {
"description": "اعمال یک اثر نوری با پخش رنگ‌های ملایم از ویدئو به پس‌زمینه صفحه نمایش شما",
"menu": {
"blur-amount": {
"label": "میزان تاری",
"submenu": {
"pixels": "{{blurAmount}} پیکسل"
}
},
"buffer": {
"label": "بافر"
},
"opacity": {
"label": "شفافیت"
},
"quality": {
"label": "کیفیت",
"submenu": {
"pixels": "{{quality}} پیکسل"
}
},
"size": {
"label": "اندازه"
},
"smoothness-transition": {
"label": "انتقال نرمی",
"submenu": {
"during": "در طول {{interpolationTime}} ثانیه"
}
},
"use-fullscreen": {
"label": "استفاده از تمام‌صفحه"
}
},
"name": "حالت محیطی"
},
"audio-compressor": {
"description": "اعمال فشرده‌سازی به صدا (کاهش حجم بلندترین بخش‌های سیگنال و افزایش حجم بخش‌های نرم‌تر)",
"name": "فشرده‌ساز صدا"
},
"blur-nav-bar": {
"description": "شفاف و محو کردن نوار ناوبری",
"name": "محو کردن نوار ناوبری"
},
"bypass-age-restrictions": {
"description": "دور زدن تأیید سن یوتیوب",
"name": "دور زدن محدودیت‌های سنی"
},
"captions-selector": {
"description": "انتخاب زیرنویس برای آهنگ‌های یوتیوب موسیقی",
"menu": {
"autoload": "به طور خودکار انتخاب آخرین زیرنویس استفاده شده",
"disable-captions": "بدون زیرنویس به صورت پیش‌فرض"
},
"name": "انتخاب‌کننده زیرنویس",
"prompt": {
"selector": {
"label": "زبان زیرنویس فعلی: {{language}}",
"none": "هیچ‌کدام",
"title": "انتخاب زبان زیرنویس"
}
},
"templates": {
"title": "باز کردن انتخاب‌کننده زیرنویس"
}
},
"compact-sidebar": {
"description": "همیشه نوار کناری را در حالت فشرده تنظیم کن",
"name": "نوار کناری فشرده"
},
"crossfade": {
"description": "تداخل بین آهنگ‌ها",
"menu": {
"advanced": "پیشرفته"
},
"name": "تداخل [بتا]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "مدت زمان ورود تدریجی (میلی‌ثانیه)",
"fade-out-duration": "مدت زمان خروج تدریجی (میلی‌ثانیه)",
"fade-scaling": {
"label": "مقیاس‌بندی ورود تدریجی",
"linear": "خطی",
"logarithmic": "لگاریتمی"
},
"seconds-before-end": "تداخل N ثانیه قبل از پایان"
},
"title": "گزینه‌های تداخل"
}
}
},
"disable-autoplay": {
"description": "شروع آهنگ در حالت \"توقف\"",
"menu": {
"apply-once": "فقط در شروع اعمال می‌شود"
},
"name": "غیرفعال کردن پخش خودکار"
},
"discord": {
"backend": {
"already-connected": "تلاش برای اتصال با اتصال فعال",
"connected": "متصل به Discord",
"disconnected": "قطع اتصال از Discord"
},
"description": "نمایش آنچه گوش می‌دهید به دوستان با Rich Presence",
"menu": {
"auto-reconnect": "اتصال خودکار مجدد",
"clear-activity": "پاک کردن فعالیت",
"clear-activity-after-timeout": "پاک کردن فعالیت پس از تایم‌اوت",
"connected": "متصل",
"disconnected": "قطع شده",
"hide-duration-left": "مخفی کردن مدت زمان باقی‌مانده",
"hide-github-button": "مخفی کردن دکمه لینک GitHub",
"play-on-youtube-music": "پخش در یوتیوب موسیقی",
"set-inactivity-timeout": "تنظیم تایم‌اوت عدم فعالیت"
},
"name": "Rich Presence در Discord",
"prompt": {
"set-inactivity-timeout": {
"label": "ورود تایم‌اوت عدم فعالیت به ثانیه:",
"title": "تنظیم تایم‌اوت عدم فعالیت"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "تأیید"
},
"message": "اوه! متاسفیم، دانلود شکست خورد…",
"title": "خطا در دانلود!"
},
"start-download-playlist": {
"buttons": {
"ok": "تأیید"
},
"detail": "({{playlistSize}} آهنگ)",
"message": "دانلود فهرست پخش {{playlistTitle}}",
"title": "دانلود شروع شد"
}
},
"feedback": {
"conversion-progress": "تبدیل: {{percent}}%",
"converting": "در حال تبدیل…",
"done": "انجام شد: {{filePath}}",
"download-info": "در حال دانلود {{artist}} - {{title}} [{{videoId}}",
"download-progress": "دانلود: {{percent}}%",
"downloading": "در حال دانلود…",
"downloading-counter": "در حال دانلود {{current}}/{{total}}…",
"downloading-playlist": "در حال دانلود فهرست پخش \"{{playlistTitle}}\" - {{playlistSize}} آهنگ ({{playlistId}})",
"error-while-downloading": "خطا در دانلود \"{{author}} - {{title}}\": {{error}}",
"folder-already-exists": "پوشه {{playlistFolder}} از قبل وجود دارد",
"getting-playlist-info": "در حال دریافت اطلاعات فهرست پخش…",
"loading": "در حال بارگذاری…",
"playlist-has-only-one-song": "فهرست پخش فقط یک آیتم دارد، به طور مستقیم دانلود می‌شود",
"playlist-id-not-found": "شناسه فهرست پخش یافت نشد"
}
}
}
}
}

View File

@ -498,6 +498,7 @@
} }
}, },
"priority": "Prioridad ng Notification", "priority": "Prioridad ng Notification",
"toast-style": "Estilo ng toast",
"unpause-notification": "Ipakita ang notification sa pag-unpause" "unpause-notification": "Ipakita ang notification sa pag-unpause"
}, },
"name": "Mga Abiso" "name": "Mga Abiso"

View File

@ -2,13 +2,14 @@
"common": { "common": {
"console": { "console": {
"plugins": { "plugins": {
"execute-failed": "נכשל ביצוע תוסף {{pluginName}}::{{contextName}}", "execute-failed": "שגיאה בהרצת התוסף {{pluginName}}::{{contextName}}",
"executed-at-ms": "התוסף {{pluginName}}:{{contextName}} בוצע ב {{ms}}ms", "executed-at-ms": "התוסף {{pluginName}}:{{contextName}} בוצע ב {{ms}}ms",
"initialize-failed": "טעינת התוסף \"{{pluginName}}\" נכשלה", "initialize-failed": "טעינת התוסף \"{{pluginName}}\" נכשלה",
"load-all": "טוען את כל התוספים", "load-all": "טוען את כל התוספים",
"load-failed": "טעינת התוסף \"{{pluginName}}\" נכשלה", "load-failed": "לא ניתן לטעון את התוסף {{pluginName}}",
"loaded": "התוסף \"{{pluginName}}\" נטען", "loaded": "התוסף \"{{pluginName}}\" נטען",
"unload-failed": "הסרת התוסף \"{{pluginName}} נכשלה" "unload-failed": "הסרת התוסף \"{{pluginName}} נכשלה",
"unloaded": "תוסף {{pluginName}} הורד"
} }
} }
}, },
@ -25,14 +26,28 @@
"i18n": { "i18n": {
"loaded": "i18n נטען" "loaded": "i18n נטען"
}, },
"second-instance": {
"receive-command": "התקבלה פקודה מעבר פרוטוקל: {{command}}"
},
"theme": { "theme": {
"css-file-not-found": "קובץ ה-CSS \"{{cssFile}}\" לא קיים. מדלג" "css-file-not-found": "קובץ ה-CSS \"{{cssFile}}\" לא קיים. מדלג"
}, },
"unresponsive": {
"details": "שגיאה ללא תגובה\n{{error}}"
},
"when-ready": { "when-ready": {
"clearing-cache-after-20s": "מוחק קבצי מתמון" "clearing-cache-after-20s": "מוחק קבצי מתמון"
},
"window": {
"tried-to-render-offscreen": "ווינדוס ניסה להציג תוכן מחוץ למסך, גודל חלון={{windowSize}}, גודל מסך={{displaySize}}, מיקום={{position}}"
} }
}, },
"dialog": { "dialog": {
"hide-menu-enabled": {
"detail": "התפריט מוחבא, השתמש \"Alt\" על להציג אותו (או \"Esacpe\" אם משתמשים בתפריט בתוך האפליקציה)",
"message": "הסתרת התפריט מופעלת",
"title": "הסתרת התפריט הופעלה"
},
"need-to-restart": { "need-to-restart": {
"buttons": { "buttons": {
"later": "אחר כך", "later": "אחר כך",

View File

@ -372,6 +372,9 @@
"backend": { "backend": {
"dialog": { "dialog": {
"error": { "error": {
"buttons": {
"ok": "Rendben"
},
"message": "Hoppá! Elnézést, a letöltés sikertelen volt…", "message": "Hoppá! Elnézést, a letöltés sikertelen volt…",
"title": "A letöltés során hiba történt!" "title": "A letöltés során hiba történt!"
}, },
@ -412,6 +415,7 @@
"menu": { "menu": {
"choose-download-folder": "Letöltési mappa kiválasztása", "choose-download-folder": "Letöltési mappa kiválasztása",
"download-finish-settings": { "download-finish-settings": {
"label": "Letöltés befejezéskor",
"prompt": { "prompt": {
"last-percent": "x százalék után", "last-percent": "x százalék után",
"last-seconds": "Utolsó x másodperc" "last-seconds": "Utolsó x másodperc"
@ -425,7 +429,7 @@
} }
}, },
"download-playlist": "Lejátszási lista letöltése", "download-playlist": "Lejátszási lista letöltése",
"presets": "Előbeállítások", "presets": "Sablonok",
"skip-existing": "Meglévő fájlok kihagyása" "skip-existing": "Meglévő fájlok kihagyása"
}, },
"name": "Letöltő", "name": "Letöltő",
@ -666,6 +670,18 @@
"fetch": "⚠️ - Hiba történt a dalszövegek lekérése közben. Kérlek, próbáld újra később.", "fetch": "⚠️ - Hiba történt a dalszövegek lekérése közben. Kérlek, próbáld újra később.",
"not-found": "⚠️ - Nem található dalszöveg ehhez a zenéhez." "not-found": "⚠️ - Nem található dalszöveg ehhez a zenéhez."
}, },
"menu": {
"line-effect": {
"submenu": {
"scale": {
"label": "Mérték"
}
}
},
"precise-timing": {
"label": "Dalszöveg tökéletes szinkronizálása"
}
},
"name": "Szinkronizált dalszövegek", "name": "Szinkronizált dalszövegek",
"refetch-btn": { "refetch-btn": {
"fetching": "Lekérés folyamatban...", "fetching": "Lekérés folyamatban...",

View File

@ -158,6 +158,14 @@
}, },
"remove-upgrade-button": "Fjarlægja uppgræðartakkan", "remove-upgrade-button": "Fjarlægja uppgræðartakkan",
"theme": { "theme": {
"dialog": {
"button": {
"cancel": "Hætta við",
"remove": "Fjarlægja"
},
"remove-theme": "Ertu viss um að þú viljir fjarlægja þetta sérsniðna þema?",
"remove-theme-message": "Þetta mun fjarlægja sérsniðna þema"
},
"label": "Þema", "label": "Þema",
"submenu": { "submenu": {
"import-css-file": "Flytja inn sérsniðna CSS skrá", "import-css-file": "Flytja inn sérsniðna CSS skrá",
@ -199,6 +207,10 @@
} }
}, },
"plugins": { "plugins": {
"ad-speedup": {
"description": "Ef auglýsing spilar slökknar hún á hljóðinu og stillir spilunarhraðann á 16x",
"name": "Auglýsingahraða"
},
"adblocker": { "adblocker": {
"description": "Lokaðu fyrir allar auglýsingar og rakningar úr kassanum", "description": "Lokaðu fyrir allar auglýsingar og rakningar úr kassanum",
"menu": { "menu": {
@ -402,6 +414,21 @@
"description": "Niðurhalar MP3 / upprunahljóði beint úr viðmótinu", "description": "Niðurhalar MP3 / upprunahljóði beint úr viðmótinu",
"menu": { "menu": {
"choose-download-folder": "Veldu niðurhalsmöppu", "choose-download-folder": "Veldu niðurhalsmöppu",
"download-finish-settings": {
"label": "Sækja þegar lokið",
"prompt": {
"last-percent": "Eftir x sekúndur",
"last-seconds": "Síðustu x sekúndur",
"title": "Stilla hvenær á að hlaða niður"
},
"submenu": {
"advanced": "Ítarlegri",
"enabled": "Virkt",
"mode": "Tímastilling",
"percent": "Hlutfall",
"seconds": "Sekúndur"
}
},
"download-playlist": "Sækja spilunarlista", "download-playlist": "Sækja spilunarlista",
"presets": "Forstillingar", "presets": "Forstillingar",
"skip-existing": "Slepptu núverandi skrám" "skip-existing": "Slepptu núverandi skrám"
@ -641,6 +668,10 @@
"description": "Sleppur sjálfkrafa hlutum sem ekki eru tónlist, eins og inngangur/lok eða hlutar af tónlistarmyndböndum þar sem lag er ekki að spila", "description": "Sleppur sjálfkrafa hlutum sem ekki eru tónlist, eins og inngangur/lok eða hlutar af tónlistarmyndböndum þar sem lag er ekki að spila",
"name": "Styrktarblokk" "name": "Styrktarblokk"
}, },
"synced-lyrics": {
"description": "Veitir samstillta texta við lög, með því að nota veitur eins og LRClib.",
"name": "Samstilltur texti"
},
"taskbar-mediacontrol": { "taskbar-mediacontrol": {
"description": "Stjórnaðu spilun frá Windows verkefnastikunni þinni", "description": "Stjórnaðu spilun frá Windows verkefnastikunni þinni",
"name": "Miðlunarstýringarverkefnastikunnar" "name": "Miðlunarstýringarverkefnastikunnar"

View File

@ -674,6 +674,42 @@
"fetch": "⚠️ - Si è verificato un errore nel recuperare il testo. Per favore riprova più tardi.", "fetch": "⚠️ - Si è verificato un errore nel recuperare il testo. Per favore riprova più tardi.",
"not-found": "⚠️ - Nessun testo trovato per questa canzone." "not-found": "⚠️ - Nessun testo trovato per questa canzone."
}, },
"menu": {
"default-text-string": {
"label": "Carattere predefinito tra i testi",
"tooltip": "Scegliere il carattere predefinito da utilizzare per l'intervallo tra i testi"
},
"line-effect": {
"label": "Effetto linea",
"submenu": {
"focus": {
"label": "Focus",
"tooltip": "Rendi bianca solo la riga corrente"
},
"offset": {
"label": "Offset",
"tooltip": "Offset a destra della riga corrente"
},
"scale": {
"label": "Ingrandimento",
"tooltip": "Ingrandisci la linea corrente"
}
},
"tooltip": "Scegli l'effetto da applicare alla linea corrente"
},
"precise-timing": {
"label": "Rendi i testi perfettamente sincronizzati",
"tooltip": "Calcola al millisecondo la visualizzazione della riga successiva (può avere un piccolo impatto sulle prestazioni)"
},
"show-lyrics-even-if-inexact": {
"label": "Mostra le lyric anche se incorrette",
"tooltip": "Se il brano non viene trovato, il plugin riprova con un'altra query di ricerca.\nIl risultato del secondo tentativo potrebbe non essere esatto."
},
"show-time-codes": {
"label": "Mostra time code",
"tooltip": "Mostra i codici temporali accanto ai testi"
}
},
"name": "Testi sincronizzati", "name": "Testi sincronizzati",
"refetch-btn": { "refetch-btn": {
"fetching": "Sto recuperando...", "fetching": "Sto recuperando...",

View File

@ -279,6 +279,49 @@
}, },
"name": "앰비언트 모드" "name": "앰비언트 모드"
}, },
"api-server": {
"description": "플레이어를 제어하기 위한 API 서버를 추가합니다",
"dialog": {
"request": {
"buttons": {
"allow": "허용",
"deny": "거부"
},
"message": "{{ID}} ({{origin}})이(가) API에 액세스하도록 허용하시겠습니까?",
"title": "API 권한 요청"
}
},
"menu": {
"auth-strategy": {
"label": "인증 정책",
"submenu": {
"auth-at-first": {
"label": "첫 번째 요청 시 인증"
},
"none": {
"label": "인증 없음"
}
}
},
"hostname": {
"label": "호스트 명"
},
"port": {
"label": "포트"
}
},
"name": "API 서버 [베타]",
"prompt": {
"hostname": {
"label": "API 서버가 사용할 호스트 명(예: 0.0.0.0)을 입력하세요:",
"title": "호스트 명"
},
"port": {
"label": "API 서버가 사용할 포트를 입력하세요:",
"title": "포트"
}
}
},
"audio-compressor": { "audio-compressor": {
"description": "오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)", "description": "오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)",
"name": "오디오 컴프레서" "name": "오디오 컴프레서"

View File

@ -668,6 +668,59 @@
"description": "Automatycznie pomija fragmenty niebędące muzyką, takie jak wstęp/zakończenie lub fragmenty teledysków, w których utwór nie jest odtwarzany", "description": "Automatycznie pomija fragmenty niebędące muzyką, takie jak wstęp/zakończenie lub fragmenty teledysków, w których utwór nie jest odtwarzany",
"name": "Pomiń nieistotne fragmenty" "name": "Pomiń nieistotne fragmenty"
}, },
"synced-lyrics": {
"description": "Dodaje zsynchronizowane napisy do utworów używając między innymi LRClib.",
"errors": {
"fetch": "⚠️ - Wystąpił błąd podczas pobierania tekstu utworu. Spróbuj ponownie później.",
"not-found": "⚠️ - Nie znaleziono napisów dla tego utworu."
},
"menu": {
"default-text-string": {
"label": "Standardowy znak luki",
"tooltip": "Wybierz domyślny znak, który ma być wyświetlany jako pauza między słowami"
},
"line-effect": {
"label": "Efekty linijki",
"submenu": {
"focus": {
"label": "Fokus",
"tooltip": "Spraw, aby tylko obecna linijka była biała"
},
"offset": {
"label": "Przesunięcie",
"tooltip": "Przesuń w prawo obecną linijkę"
},
"scale": {
"label": "Skala",
"tooltip": "Zmień skalę aktualnej linijki"
}
},
"tooltip": "Wybierz efekt, by zastosować go do aktualnej linijki"
},
"precise-timing": {
"label": "Zsynchronizuj tekst utworu do perfekcji",
"tooltip": "Wylicz czas wyświetlania następnej linijki co do milisekundy (może mieć mały wpływ na wydajność systemu)"
},
"show-lyrics-even-if-inexact": {
"label": "Pokaż teksty, mimo niezgodności",
"tooltip": "Jeżeli nie znaleziono tekstu piosenki z bazy danych, wtyczka spróbuje ponownie przez wyszukanie przybliżonej frazy.\nNależy jednak pamiętać, że następne próby mogą nie być trafne co do oryginału."
},
"show-time-codes": {
"label": "Pokaż znaczniki czasu",
"tooltip": "Pokaż znaczniki czasu obok linijek"
}
},
"name": "Napisy zsynchronizowane",
"refetch-btn": {
"fetching": "Pobieranie napisów...",
"normal": "Odśwież napisy"
},
"warnings": {
"duration-mismatch": "⚠️ - Napisy mogą nie być zsynchronizowane z powodu różnicy w czasie trwania utworu.",
"inexact": "⚠️ - Tekst utworu może się różnić od oryginału",
"instrumental": "⚠️ - To jest utwór instrumentalny"
}
},
"taskbar-mediacontrol": { "taskbar-mediacontrol": {
"description": "Steruj odtwarzaniem z paska zadań systemu Windows", "description": "Steruj odtwarzaniem z paska zadań systemu Windows",
"name": "Kontroler odtwarzania z paska zadań" "name": "Kontroler odtwarzania z paska zadań"

View File

@ -0,0 +1,770 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "Falha ao executar plugin {{pluginName}}::{{contextName}}",
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} executado em {{ms}} ms",
"initialize-failed": "Falha ao inicializar o plugin \"{{pluginName}}\"",
"load-all": "Carregando todos os plugins",
"load-failed": "Falha ao carregar o plugin \"{{pluginName}}\"",
"loaded": "Plugin \"{{pluginName}}\" carregado",
"unload-failed": "Falha ao descarregar o plugin \"{{pluginName}}\"",
"unloaded": "Plugin \"{{pluginName}}\" descarregado"
}
}
},
"language": {
"code": "pt-BR",
"local-name": "Português (Brasil)",
"name": "Portuguese (Brazil)"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "Carregamento concluído. DevTools aberto"
},
"i18n": {
"loaded": "i18n carregado"
},
"second-instance": {
"receive-command": "Comando recebido pelo protocolo: \"{{command}}\""
},
"theme": {
"css-file-not-found": "Arquivo CSS \"{{cssFile}}\" não existe, ignorando"
},
"unresponsive": {
"details": "Erro de falta de resposta!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "Limpando cache do aplicativo"
},
"window": {
"tried-to-render-offscreen": "A janela tentou renderizar fora da tela, tamanho da janela={{windowSize}}, tamanho da tela={{displaySize}}, posição={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "O menu está oculto, use 'Alt' para mostrá-lo (ou 'Esc' ao usar o menu dentro do aplicativo)",
"message": "Ocultar menu está ativado",
"title": "Ocultar menu ativado"
},
"need-to-restart": {
"buttons": {
"later": "Depois",
"restart-now": "Reiniciar agora"
},
"detail": "O plugin \"{{pluginName}}\" requer uma reinicialização para entrar em vigor",
"message": "\"{{pluginName}}\" precisa reiniciar",
"title": "Necessário reiniciar"
},
"unresponsive": {
"buttons": {
"quit": "Fechar",
"relaunch": "Reiniciar",
"wait": "Aguardar"
},
"detail": "Lamentamos o inconveniente! Por favor, escolha o que fazer:",
"message": "O aplicativo não está respondendo",
"title": "Janela não responde"
},
"update-available": {
"buttons": {
"disable": "Desativar atualizações",
"download": "Baixar",
"ok": "OK"
},
"detail": "Uma versão mais recente está disponível em {{downloadLink}}",
"message": "Nova versão disponível",
"title": "Atualização disponível"
}
},
"menu": {
"about": "Sobre",
"navigation": {
"label": "Navegação",
"submenu": {
"copy-current-url": "Copiar URL atual",
"go-back": "Voltar",
"go-forward": "Avançar",
"quit": "Sair",
"restart": "Reiniciar aplicativo"
}
},
"options": {
"label": "Opções",
"submenu": {
"advanced-options": {
"label": "Opções avançadas",
"submenu": {
"auto-reset-app-cache": "Limpar cache ao iniciar aplicativo",
"disable-hardware-acceleration": "Desativar aceleração de hardware",
"edit-config-json": "Editar config.json",
"override-user-agent": "Substituir User-Agent",
"restart-on-config-changes": "Reiniciar ao alterar configurações",
"set-proxy": {
"label": "Definir proxy",
"prompt": {
"label": "Digite o endereço do proxy: (deixe em branco para desativar)",
"placeholder": "Exemplo: SOCKS5://127.0.0.1:9999",
"title": "Definir proxy"
}
},
"toggle-dev-tools": "Alternar DevTools"
}
},
"always-on-top": "Sempre no topo",
"auto-update": "Atualização automática",
"hide-menu": {
"dialog": {
"message": "O menu ficará oculto na próxima inicialização, use [Alt] para exibi-lo (ou a tecla de crase [`] se estiver usando o menu do aplicativo)",
"title": "Ocultar menu ativado"
},
"label": "Ocultar menu"
},
"language": {
"dialog": {
"message": "O idioma será alterado depois de reiniciar",
"title": "Idioma alterado"
},
"label": "Idioma",
"submenu": {
"to-help-translate": "Quer ajudar a traduzir? Clique aqui"
}
},
"resume-on-start": "Continuar última música ao iniciar o aplicativo",
"single-instance-lock": "Bloqueio de instância única",
"start-at-login": "Iniciar com o sistema",
"starting-page": {
"label": "Página inicial",
"unset": "Limpar"
},
"tray": {
"label": "Área de Notificação",
"submenu": {
"disabled": "Desativado",
"enabled-and-hide-app": "Ativado e aplicativo oculto",
"enabled-and-show-app": "Ativado e mostrar aplicativo",
"play-pause-on-click": "Reproduzir/Pausar ao clicar"
}
},
"visual-tweaks": {
"label": "Ajustes visuais",
"submenu": {
"like-buttons": {
"default": "Padrão",
"force-show": "Forçar exibir",
"hide": "Ocultar",
"label": "Botões de 'Curtir'"
},
"remove-upgrade-button": "Remover botão de atualização",
"theme": {
"dialog": {
"button": {
"cancel": "Cancelar",
"remove": "Remover"
},
"remove-theme": "Deseja realmente remover o tema personalizado?",
"remove-theme-message": "Isto removerá o tema personalizado"
},
"label": "Tema",
"submenu": {
"import-css-file": "Importar arquivo CSS personalizado",
"no-theme": "Sem tema"
}
}
}
}
}
},
"plugins": {
"enabled": "Ativado",
"label": "Plugins",
"new": "NOVO"
},
"view": {
"label": "Visualização",
"submenu": {
"force-reload": "Forçar recarregar",
"reload": "Recarregar",
"reset-zoom": "Tamanho atual",
"toggle-fullscreen": "Alternar tela cheia",
"zoom-in": "Ampliar",
"zoom-out": "Reduzir"
}
}
},
"tray": {
"next": "Próximo",
"play-pause": "Reproduzir/Pausar",
"previous": "Anterior",
"quit": "Sair",
"restart": "Reiniciar aplicativo",
"show": "Mostrar janela",
"tooltip": {
"default": "YouTube Music",
"with-song-info": "YouTube Music: {{artist}} - {{title}}"
}
}
},
"plugins": {
"ad-speedup": {
"description": "Se um anúncio for reproduzido, ele silencia o áudio e define a velocidade de reprodução para 16x",
"name": "Acelerador de Anúncios"
},
"adblocker": {
"description": "Bloqueio de todos os anúncios e rastreamentos imediatamente",
"menu": {
"blocker": "Bloqueador"
},
"name": "Bloqueador de Anúncios"
},
"album-actions": {
"description": "Adiciona botões Remover Não curtir, Não curtir, Curtir e Remover Curtir para aplicar em todas as músicas em uma lista ou álbum",
"name": "Ações do álbum"
},
"album-color-theme": {
"description": "Aplica um tema dinâmico e efeitos visuais com base na paleta de cores do álbum",
"menu": {
"color-mix-ratio": {
"label": "Proporção de mistura de cores",
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Tema da cor do álbum"
},
"ambient-mode": {
"description": "Aplica um efeito de iluminação projetando cores suaves do vídeo no fundo da tela",
"menu": {
"blur-amount": {
"label": "Quantidade de desfoque",
"submenu": {
"pixels": "{{blurAmount}} pixels"
}
},
"buffer": {
"label": "Buffer",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Opacidade",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Qualidade",
"submenu": {
"pixels": "{{quality}} pixels"
}
},
"size": {
"label": "Tamanho",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Transição suave",
"submenu": {
"during": "Durante {{interpolationTime}} s"
}
},
"use-fullscreen": {
"label": "Usando tela cheia"
}
},
"name": "Modo ambiente"
},
"audio-compressor": {
"description": "Aplicar compressão ao áudio (reduz o volume das partes mais altas e aumenta o volume das partes mais baixas)",
"name": "Compressor de áudio"
},
"blur-nav-bar": {
"description": "Torna a barra de navegação transparente e desfocada",
"name": "Desfocar barra de navegação"
},
"bypass-age-restrictions": {
"description": "Pular a verificação de idade do YouTube",
"name": "Ignorar restrições de idade"
},
"captions-selector": {
"description": "Seletor de legendas para faixas de áudio do YouTube Music",
"menu": {
"autoload": "Selecionar automaticamente a última legenda usada",
"disable-captions": "Sem legendas por padrão"
},
"name": "Seletor de legendas",
"prompt": {
"selector": {
"label": "Idioma atual da legenda: {{language}}",
"none": "Nenhum",
"title": "Selecionar idioma da legenda"
}
},
"templates": {
"title": "Abrir seletor de legendas"
}
},
"compact-sidebar": {
"description": "Sempre definir a barra lateral no modo compacto",
"name": "Barra lateral compacta"
},
"crossfade": {
"description": "Crossfade entre músicas",
"menu": {
"advanced": "Avançado"
},
"name": "Crossfade [Beta]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "Duração do fade (ms)",
"fade-out-duration": "Duração do fade out (ms)",
"fade-scaling": {
"label": "Escala do fade",
"linear": "Linear",
"logarithmic": "Logarítmico"
},
"seconds-before-end": "Crossfade N segundos antes do fim"
},
"title": "Opções de crossfade"
}
}
},
"disable-autoplay": {
"description": "Faz a música começar no modo \"pausado\"",
"menu": {
"apply-once": "Aplicar somente ao iniciar"
},
"name": "Desativar reprodução automática"
},
"discord": {
"backend": {
"already-connected": "Tentativa de conectar-se com conexão ativa",
"connected": "Conectado no Discord",
"disconnected": "Desconectado do Discord"
},
"description": "Mostre aos seus amigos o que você ouve com Rich Presence",
"menu": {
"auto-reconnect": "Reconexão automática",
"clear-activity": "Limpar atividades",
"clear-activity-after-timeout": "Limpar atividades após tempo limite",
"connected": "Conectado",
"disconnected": "Desconectado",
"hide-duration-left": "Ocultar duração restante",
"hide-github-button": "Ocultar botão do GitHub",
"play-on-youtube-music": "Reproduzir no YouTube Music",
"set-inactivity-timeout": "Definir tempo limite de inatividade"
},
"name": "Rich Presence do Discord",
"prompt": {
"set-inactivity-timeout": {
"label": "Digite o tempo de inatividade em segundos:",
"title": "Definir tempo limite de inatividade"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Ah! Desculpe, o download falhou…",
"title": "Erro no download!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{playlistSize}} músicas)",
"message": "Baixando lista de reprodução {{playlistTitle}}",
"title": "Download iniciado"
}
},
"feedback": {
"conversion-progress": "Convertendo: {{percent}}%",
"converting": "Convertendo…",
"done": "Concluído: {{filePath}}",
"download-info": "Baixando {{artist}} - {{title}} [{{videoId}}",
"download-progress": "Download: {{percent}}%",
"downloading": "Baixando…",
"downloading-counter": "Baixando {{current}}/{{total}}…",
"downloading-playlist": "Baixando lista de reprodução \"{{playlistTitle}}\" - {{playlistSize}} músicas ({{playlistId}})",
"error-while-downloading": "Erro ao baixar \"{{author}} - {{title}}\": {{error}}",
"folder-already-exists": "A pasta {{playlistFolder}} já existe",
"getting-playlist-info": "Obtendo informações da playlist…",
"loading": "Carregando…",
"playlist-has-only-one-song": "Playlist possui apenas um item, baixando diretamente",
"playlist-id-not-found": "Nenhum playlist ID encontrado",
"playlist-is-empty": "Playlist está vazia",
"playlist-is-mix-or-private": "Erro ao obter informações da playlist: verifique se não é uma playlist privada ou “”Mixada para você”\n\n{{error}}",
"preparing-file": "Preparando arquivo…",
"saving": "Salvando…",
"trying-to-get-playlist-id": "Tentando obter playlist ID: {{playlistId}}",
"video-id-not-found": "Vídeo não encontrado",
"writing-id3": "Salvando tags ID3…"
}
},
"description": "Faça download do MP3 / fonte de áudio diretamente da interface",
"menu": {
"choose-download-folder": "Escolha a pasta de download",
"download-finish-settings": {
"label": "Baixar ao finalizar",
"prompt": {
"last-percent": "Após x %",
"last-seconds": "Últimos x segundos",
"title": "Configurar quando baixar"
},
"submenu": {
"advanced": "Avançado",
"enabled": "Ativado",
"mode": "Modo de tempo",
"percent": "Porcento",
"seconds": "Segundos"
}
},
"download-playlist": "Baixar playlist",
"presets": "Predefinições",
"skip-existing": "Pular arquivos existentes"
},
"name": "Downloader",
"renderer": {
"can-not-update-progress": "Não é possível atualizar o progresso"
},
"templates": {
"button": "Baixar"
}
},
"exponential-volume": {
"description": "Torna o controle deslizante de volume exponencial para que seja mais fácil selecionar volumes mais baixos.",
"name": "Volume Exponencial"
},
"in-app-menu": {
"description": "Dá às barras de menu uma aparência elegante, escura ou com a cor do álbum",
"menu": {
"hide-dom-window-controls": "Ocultar controles da janela DOM"
},
"name": "Menu no aplicativo"
},
"lumiastream": {
"description": "Adiciona suporte ao Lumia Stream",
"name": "Lumia Stream [Beta]"
},
"lyrics-genius": {
"description": "Adiciona suporte a letras para a maioria das músicas",
"menu": {
"romanized-lyrics": "Letras Romanizadas"
},
"name": "Letras Genius",
"renderer": {
"fetched-lyrics": "Letras buscadas por Genius"
}
},
"music-together": {
"description": "Compartilhe uma playlist com outras pessoas. Quando o anfitrião toca uma música, todos os outros ouvirão",
"dialog": {
"enter-host": "Insira o ID do host"
},
"internal": {
"save": "Salvar",
"track-source": "Fonte da Faixa",
"unknown-user": "Usuário Desconhecido"
},
"menu": {
"click-to-copy-id": "Copiar ID do host",
"close": "Fechar Music Together",
"connected-users": "Usuários Conectados",
"disconnect": "Desconectar Music Together",
"empty-user": "Nenhum usuário conectado",
"host": "Anfitrião do Music Together",
"join": "Entrar no Music Together",
"permission": {
"all": "Permitir que os convidados controlem a lista de reprodução e o player",
"host-only": "Somente o host pode controlar a lista de reprodução e o player",
"playlist": "Permitir que os convidados controlem a lista de reprodução"
},
"set-permission": "Mudar Permissões de Controle",
"status": {
"disconnected": "Desconectado",
"guest": "Conectado como convidado",
"host": "Conectado como Anfitrião"
}
},
"name": "Music Together [Beta]",
"toast": {
"add-song-failed": "Falha ao adicionar música",
"closed": "Music Together fechado",
"disconnected": "Music Together desconectado",
"host-failed": "Falha ao hospedar o Music Together",
"id-copied": "ID do anfitrião copiado para a área de transferência",
"id-copy-failed": "Falha ao copiar o ID do anfitrião para a área de transferência",
"join-failed": "Falha ao ingressar no Music Together",
"joined": "Entrou no Music Together",
"permission-changed": "A permissão do Music Together foi alterada para \"{{permission}}\"",
"remove-song-failed": "Falha ao remover música",
"user-connected": "{{name}} juntou-se ao Music Together",
"user-disconnected": "{{name}} saiu do Music Together"
}
},
"navigation": {
"description": "Setas de navegação para avançar/retornar diretamente integradas na interface, como no seu navegador favorito",
"name": "Navegação"
},
"no-google-login": {
"description": "Remova os botões e links de login do Google da interface",
"name": "Sem login do Google"
},
"notifications": {
"description": "Exibir uma notificação quando uma música começar a tocar (notificações interativas estão disponíveis no Windows)",
"menu": {
"interactive": "Notificações interativas",
"interactive-settings": {
"label": "Configurações interativas",
"submenu": {
"hide-button-text": "Ocultar texto do botão",
"refresh-on-play-pause": "Atualizar ao Reproduzir/Pausar",
"tray-controls": "Abrir/Fechar ao clicar na área de notificação"
}
},
"priority": "Prioridade da notificação",
"toast-style": "Estilo de alerta",
"unpause-notification": "Mostrar notificação ao despausar"
},
"name": "Notificações"
},
"picture-in-picture": {
"description": "Permite alternar o aplicativo para o modo picture-in-picture",
"menu": {
"always-on-top": "Sempre no topo",
"hotkey": {
"label": "Tecla de atalho",
"prompt": {
"keybind-options": {
"hotkey": "Tecla de atalho"
},
"label": "Escolha uma tecla de atalho para alternar entre picture-in-picture",
"title": "Atalho do picture-in-picture"
}
},
"save-window-position": "Salvar posição da janela",
"save-window-size": "Salvar tamanho da janela",
"use-native-pip": "Usar PiP nativo do navegador"
},
"name": "Picture-in-picture",
"templates": {
"button": "Picture-in-picture"
}
},
"playback-speed": {
"description": "Ouça rápido, ouça devagar! Adiciona um controle deslizante que controla a velocidade da música",
"name": "Velocidade de reprodução",
"templates": {
"button": "Velocidade"
}
},
"precise-volume": {
"description": "Controle o volume com precisão usando a roda do mouse/teclas de atalho, com um HUD personalizado e etapas de volume personalizáveis",
"menu": {
"arrows-shortcuts": "Controles de teclas de seta locais",
"custom-volume-steps": "Definir etapas de volume personalizadas",
"global-shortcuts": "Teclas de atalho globais"
},
"name": "Volume preciso",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Diminuir volume",
"increase": "Aumentar volume"
},
"label": "Selecione as teclas de atalho global do volume:",
"title": "Teclas de atalho global de volume"
},
"volume-steps": {
"label": "Escolha as etapas de aumento/diminuição do volume",
"title": "Fases de volume"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "Qualidade atual: {{quality}}",
"message": "Escolher qualidade do vídeo:",
"title": "Escolher qualidade do vídeo"
}
}
},
"description": "Permite alterar a qualidade do vídeo com um botão na sobreposição de vídeo",
"name": "Alterador de qualidade do vídeo"
},
"scrobbler": {
"description": "Adicionar suporte para scrobbling (last.fm, Listenbrainz, etc.)",
"dialog": {
"lastfm": {
"auth-failed": {
"message": "Falha ao autenticar com Last.fm\nOcultar o pop-up até a próxima reinicialização.",
"title": "Falha na autenticação"
}
}
},
"menu": {
"lastfm": {
"api-settings": "Configurações da API do Last.fm"
},
"listenbrainz": {
"token": "Insira o token de usuário ListenBrainz"
},
"scrobble-other-media": "Scrobble outras mídias"
},
"name": "Scrobbler",
"prompt": {
"lastfm": {
"api-key": "Chave de API do Last.fm",
"api-secret": "Chave secreta da API do Last.fm"
},
"listenbrainz": {
"token": {
"label": "Insira seu token de usuário do ListenBrainz:",
"title": "ListenBrainz token"
}
}
}
},
"shortcuts": {
"description": "Permite definir teclas de atalho globais para reprodução (reproduzir/pausar/próximo/anterior) e desativar o OSD de mídia substituindo as teclas de mídia, ativando Ctrl/CMD + F para pesquisar, ativando o suporte Linux MPRIS para teclas de mídia e teclas de atalho personalizadas para usuários avançados",
"menu": {
"override-media-keys": "Substituir chaves de multimédia",
"set-keybinds": "Definir controles globais de música"
},
"name": "Atalhos (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Próximo",
"play-pause": "Reproduzir / Pausar",
"previous": "Anterior"
},
"label": "Escolha atalhos de teclado globais para controle de músicas:",
"title": "Atalhos de teclado global"
}
}
},
"skip-disliked-songs": {
"description": "Ignora músicas marcadas com \"não gostei\"",
"name": "Pular músicas marcadas com \"não gostei\""
},
"skip-silences": {
"description": "Pular automaticamente seções de silêncio em músicas",
"name": "Pular silêncios"
},
"sponsorblock": {
"description": "Pula automaticamente partes não musicais, como introdução/finalização ou partes de videoclipes onde a música não está tocando",
"name": "SponsorBlock [Bloquear patrocínios]"
},
"synced-lyrics": {
"description": "Fornece letras sincronizadas para músicas, usando provedores como LRClib.",
"errors": {
"fetch": "⚠️ - Ocorreu um erro ao buscar a letra. Tente novamente mais tarde.",
"not-found": "⚠️ - Nenhuma letra encontrada para esta música."
},
"menu": {
"default-text-string": {
"label": "Caractere padrão entre letras",
"tooltip": "Escolha o caractere padrão a ser usado para o intervalo entre as letras"
},
"line-effect": {
"label": "Efeito de linha",
"submenu": {
"focus": {
"label": "Foco",
"tooltip": "Deixe apenas a linha atual branca"
},
"offset": {
"label": "Deslocar",
"tooltip": "Deslocamento à direita da linha atual"
},
"scale": {
"label": "Aumentar",
"tooltip": "Aumentar a linha atual"
}
},
"tooltip": "Escolha o efeito a ser aplicado à linha atual"
},
"precise-timing": {
"label": "Deixa as letras perfeitamente sincronizadas",
"tooltip": "Calcular até o milissegundo a exibição da próxima linha (pode ter um pequeno impacto no desempenho)"
},
"show-lyrics-even-if-inexact": {
"label": "Mostrar letras mesmo que não sejam exatas",
"tooltip": "Se a música não for encontrada, o plugin tenta novamente com uma consulta de pesquisa diferente.\nO resultado da segunda tentativa pode não ser exato."
},
"show-time-codes": {
"label": "Mostrar códigos de tempo",
"tooltip": "Mostrar os códigos de tempo ao lado das letras"
}
},
"name": "Letras sincronizadas",
"refetch-btn": {
"fetching": "Buscando...",
"normal": "Buscar letras novamente"
},
"warnings": {
"duration-mismatch": "⚠️ - A letra pode estar dessincronizada devido a uma incompatibilidade de duração.",
"inexact": "⚠️ - A letra desta música pode não ser exata",
"instrumental": "⚠️ - Esta é uma música instrumental"
}
},
"taskbar-mediacontrol": {
"description": "Controle a reprodução na barra de tarefas do Windows",
"name": "Controle de mídia da barra de tarefas"
},
"touchbar": {
"description": "Adiciona um widget TouchBar para usuários do macOS",
"name": "TouchBar"
},
"tuna-obs": {
"description": "Integração com o plugin Tuna do OBS",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "Adiciona um botão para alternar entre o modo Vídeo/Música. Também é possível remover opcionalmente toda a aba de vídeo",
"menu": {
"align": {
"label": "Alinhamento",
"submenu": {
"left": "Esquerda",
"middle": "Meio",
"right": "Direita"
}
},
"force-hide": "Forçar remoção da aba de vídeo",
"mode": {
"label": "Modo",
"submenu": {
"custom": "Alternância personalizada",
"disabled": "Desativado",
"native": "Alternância nativa"
}
}
},
"name": "Alternar vídeo",
"templates": {
"button": "Música"
}
},
"visualizer": {
"description": "Adiciona um visualizador ao player",
"menu": {
"visualizer-type": "Tipo de visualizador"
},
"name": "Visualizador"
}
}
}

View File

@ -410,6 +410,17 @@
"description": "Завантажує MP3 / джерело аудіо безпосередньо з інтерфейсу", "description": "Завантажує MP3 / джерело аудіо безпосередньо з інтерфейсу",
"menu": { "menu": {
"choose-download-folder": "Оберіть папку для завантаження", "choose-download-folder": "Оберіть папку для завантаження",
"download-finish-settings": {
"prompt": {
"last-percent": "Після Х відсотків",
"last-seconds": "Останні Х секунд"
},
"submenu": {
"enabled": "Увімкнено",
"percent": "Відсотків",
"seconds": "Секунди"
}
},
"download-playlist": "Завантажити плейлист", "download-playlist": "Завантажити плейлист",
"presets": "Попередні налаштування", "presets": "Попередні налаштування",
"skip-existing": "Пропустити наявні файли" "skip-existing": "Пропустити наявні файли"
@ -649,6 +660,53 @@
"description": "Автоматично пропускати немузичні частини, такі як вступ/закінчення або частини музичних відеороликів, де не відтворюється музика", "description": "Автоматично пропускати немузичні частини, такі як вступ/закінчення або частини музичних відеороликів, де не відтворюється музика",
"name": "SponsorBlock" "name": "SponsorBlock"
}, },
"synced-lyrics": {
"errors": {
"fetch": "⚠️ - При завантаженні тексту сталась помилка. Спробуйте ще раз пізніше.",
"not-found": "⚠️ - До цієї пісні текст не знайдено."
},
"menu": {
"default-text-string": {
"label": "Символ за замовчуванням між текстами пісень",
"tooltip": "Виберіть символ за замовчуванням, який буде використовуватися для проміжку між текстами пісень"
},
"line-effect": {
"label": "Лінійний ефект",
"submenu": {
"focus": {
"label": "Зосереджитись",
"tooltip": "Зробити білим лише поточний рядок"
},
"scale": {
"label": "Масштабувати",
"tooltip": "Масштабуваты поточну лінію"
}
},
"tooltip": "Виберіть ефект, який потрібно застосувати до поточної лінії"
},
"precise-timing": {
"label": "Зробити текст пісні ідеально синхронізованим"
},
"show-lyrics-even-if-inexact": {
"label": "Показувати текст пісні, навіть якщо він неточний",
"tooltip": "Якщо пісня не знайдена, плагін повторює спробу з іншим пошуковим запитом.\nРезультат з другої спроби може бути не точним."
},
"show-time-codes": {
"label": "Показувати часові марки",
"tooltip": "Показує часові маркы поруч із текстом пісні"
}
},
"name": "Синхронізовані тексти",
"refetch-btn": {
"fetching": "Отримання...",
"normal": "Перезавантажити текст"
},
"warnings": {
"duration-mismatch": "⚠️ - Тексти цієї пісні можуть бути не синхронізовані через не співпадіння довжини пісні.",
"inexact": "⚠️ - Текст цієї пісні може не співпадати",
"instrumental": "⚠️ - Це інструментал"
}
},
"taskbar-mediacontrol": { "taskbar-mediacontrol": {
"description": "Керування відтворенням з панелі завдань Windows", "description": "Керування відтворенням з панелі завдань Windows",
"name": "Керування медіа на панелі завдань" "name": "Керування медіа на панелі завдань"

View File

@ -11,6 +11,7 @@ import {
shell, shell,
dialog, dialog,
ipcMain, ipcMain,
type BrowserWindowConstructorOptions,
} from 'electron'; } from 'electron';
import enhanceWebRequest, { import enhanceWebRequest, {
BetterSession, BetterSession,
@ -56,7 +57,6 @@ import { loadI18n, setLanguage, t } from '@/i18n';
import ErrorHtmlAsset from '@assets/error.html?asset'; import ErrorHtmlAsset from '@assets/error.html?asset';
import type { PluginConfig } from '@/types/plugins'; import type { PluginConfig } from '@/types/plugins';
import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions;
if (!is.macOS()) { if (!is.macOS()) {
delete allPlugins['touchbar']; delete allPlugins['touchbar'];
@ -334,7 +334,9 @@ async function createMainWindow() {
const display = screen.getDisplayNearestPoint(windowPosition); const display = screen.getDisplayNearestPoint(windowPosition);
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const scaleFactor = is.windows() ? primaryDisplay.scaleFactor / display.scaleFactor : 1; const scaleFactor = is.windows()
? primaryDisplay.scaleFactor / display.scaleFactor
: 1;
const scaledWidth = Math.floor(windowSize.width * scaleFactor); const scaledWidth = Math.floor(windowSize.width * scaleFactor);
const scaledHeight = Math.floor(windowSize.height * scaleFactor); const scaledHeight = Math.floor(windowSize.height * scaleFactor);
@ -342,10 +344,10 @@ async function createMainWindow() {
const scaledY = windowY; const scaledY = windowY;
if ( if (
scaledX + (scaledWidth / 2) < display.bounds.x - 8 || // Left scaledX + scaledWidth / 2 < display.bounds.x - 8 || // Left
scaledX + (scaledWidth / 2) > display.bounds.x + display.bounds.width || // Right scaledX + scaledWidth / 2 > display.bounds.x + display.bounds.width || // Right
scaledY < display.bounds.y - 8 || // Top scaledY < display.bounds.y - 8 || // Top
scaledY + (scaledHeight / 2) > display.bounds.y + display.bounds.height // Bottom scaledY + scaledHeight / 2 > display.bounds.y + display.bounds.height // Bottom
) { ) {
// Window is offscreen // Window is offscreen
if (is.dev()) { if (is.dev()) {
@ -442,7 +444,7 @@ async function createMainWindow() {
...defaultTitleBarOverlayOptions, ...defaultTitleBarOverlayOptions,
height: Math.floor( height: Math.floor(
defaultTitleBarOverlayOptions.height! * defaultTitleBarOverlayOptions.height! *
win.webContents.getZoomFactor(), win.webContents.getZoomFactor(),
), ),
}); });
} }
@ -455,7 +457,7 @@ async function createMainWindow() {
event.preventDefault(); event.preventDefault();
win.webContents.loadURL( win.webContents.loadURL(
'https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F' 'https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F',
); );
} }
}); });
@ -479,8 +481,8 @@ app.once('browser-window-created', (_event, win) => {
const updatedUserAgent = is.macOS() const updatedUserAgent = is.macOS()
? userAgents.mac ? userAgents.mac
: is.windows() : is.windows()
? userAgents.windows ? userAgents.windows
: userAgents.linux; : userAgents.linux;
win.webContents.userAgent = updatedUserAgent; win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent; app.userAgentFallback = updatedUserAgent;
@ -618,6 +620,7 @@ app.whenReady().then(async () => {
shortcutDetails.target !== appLocation || shortcutDetails.target !== appLocation ||
shortcutDetails.appUserModelId !== appID shortcutDetails.appUserModelId !== appID
) { ) {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw 'needUpdate'; throw 'needUpdate';
} }
} catch (error) { } catch (error) {
@ -641,7 +644,9 @@ app.whenReady().then(async () => {
// In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync // In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync
if (is.dev() && process.env.ELECTRON_RENDERER_URL) { if (is.dev() && process.env.ELECTRON_RENDERER_URL) {
// HACK: to make vite work with electron renderer (supports hot reload) // HACK: to make vite work with electron renderer (supports hot reload)
event.returnValue = [null, ` event.returnValue = [
null,
`
console.log('${LoggerPrefix}', 'Loading vite from dev server'); console.log('${LoggerPrefix}', 'Loading vite from dev server');
(async () => { (async () => {
await new Promise((resolve) => { await new Promise((resolve) => {
@ -662,7 +667,8 @@ app.whenReady().then(async () => {
document.body.appendChild(rendererScript); document.body.appendChild(rendererScript);
})(); })();
0 0
`]; `,
];
} else { } else {
const rendererPath = path.join(__dirname, '..', 'renderer'); const rendererPath = path.join(__dirname, '..', 'renderer');
const indexHTML = parse( const indexHTML = parse(
@ -674,7 +680,10 @@ app.whenReady().then(async () => {
scriptSrc.getAttribute('src')!, scriptSrc.getAttribute('src')!,
); );
const scriptString = fs.readFileSync(scriptPath, 'utf-8'); const scriptString = fs.readFileSync(scriptPath, 'utf-8');
event.returnValue = [url.pathToFileURL(scriptPath).toString(), scriptString + ';0']; event.returnValue = [
url.pathToFileURL(scriptPath).toString(),
scriptString + ';0',
];
} }
}); });

View File

@ -34,11 +34,12 @@ const createContext = (
win.webContents.send(event, ...args); win.webContents.send(event, ...args);
}, },
handle: (event: string, listener: CallableFunction) => { handle: (event: string, listener: CallableFunction) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-call
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args)); ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
}, },
on: (event: string, listener: CallableFunction) => { on: (event: string, listener: CallableFunction) => {
ipcMain.on(event, (_, ...args: unknown[]) => { ipcMain.on(event, (_, ...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
listener(...args); listener(...args);
}); });
}, },
@ -75,11 +76,11 @@ export const forceUnloadMainPlugin = async (
); );
return; return;
} else { } else {
console.log( const message = t('common.console.plugins.unload-failed', {
LoggerPrefix, pluginName: id,
t('common.console.plugins.unload-failed', { pluginName: id }), });
); console.log(LoggerPrefix, message);
return Promise.reject(); return Promise.reject(new Error(message));
} }
} catch (err) { } catch (err) {
console.error( console.error(
@ -87,7 +88,7 @@ export const forceUnloadMainPlugin = async (
t('common.console.plugins.unload-failed', { pluginName: id }), t('common.console.plugins.unload-failed', { pluginName: id }),
); );
console.trace(err); console.trace(err);
return Promise.reject(err); return Promise.reject(err as Error);
} }
}; };
@ -111,11 +112,11 @@ export const forceLoadMainPlugin = async (
) { ) {
loadedPluginMap[id] = plugin; loadedPluginMap[id] = plugin;
} else { } else {
console.log( const message = t('common.console.plugins.load-failed', {
LoggerPrefix, pluginName: id,
t('common.console.plugins.load-failed', { pluginName: id }), });
); console.log(LoggerPrefix, message);
return Promise.reject(); return Promise.reject(new Error(message));
} }
} catch (err) { } catch (err) {
console.error( console.error(
@ -123,7 +124,7 @@ export const forceLoadMainPlugin = async (
t('common.console.plugins.initialize-failed', { pluginName: id }), t('common.console.plugins.initialize-failed', { pluginName: id }),
); );
console.trace(err); console.trace(err);
return Promise.reject(err); return Promise.reject(err as Error);
} }
}; };

View File

@ -18,7 +18,8 @@ const loadedPluginMap: Record<
export const createContext = <Config extends PluginConfig>( export const createContext = <Config extends PluginConfig>(
id: string, id: string,
): RendererContext<Config> => ({ ): RendererContext<Config> => ({
getConfig: async () => window.ipcRenderer.invoke('ytmd:get-config', id), getConfig: async () =>
window.ipcRenderer.invoke('ytmd:get-config', id) as Promise<Config>,
setConfig: async (newConfig) => { setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('ytmd:set-config', id, newConfig); await window.ipcRenderer.invoke('ytmd:set-config', id, newConfig);
}, },
@ -30,6 +31,7 @@ export const createContext = <Config extends PluginConfig>(
window.ipcRenderer.invoke(event, ...args), window.ipcRenderer.invoke(event, ...args),
on: (event: string, listener: CallableFunction) => { on: (event: string, listener: CallableFunction) => {
window.ipcRenderer.on(event, (_, ...args: unknown[]) => { window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
listener(...args); listener(...args);
}); });
}, },

View File

@ -1,5 +1,13 @@
import is from 'electron-is'; import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu, MenuItem, shell, } from 'electron'; import {
app,
BrowserWindow,
clipboard,
dialog,
Menu,
MenuItem,
shell,
} from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { satisfies } from 'semver'; import { satisfies } from 'semver';
@ -68,12 +76,21 @@ export const mainMenuTemplate = async (
const plugin = allPlugins[id]; const plugin = allPlugins[id];
const pluginLabel = plugin?.name?.() ?? id; const pluginLabel = plugin?.name?.() ?? id;
const pluginDescription = plugin?.description?.() ?? undefined; const pluginDescription = plugin?.description?.() ?? undefined;
const isNew = plugin?.addedVersion ? satisfies(packageJson.version, plugin.addedVersion) : false; const isNew = plugin?.addedVersion
? satisfies(packageJson.version, plugin.addedVersion)
: false;
if (!config.plugins.isEnabled(id)) { if (!config.plugins.isEnabled(id)) {
return [ return [
id, id,
pluginEnabledMenu(id, pluginLabel, pluginDescription, isNew, true, innerRefreshMenu), pluginEnabledMenu(
id,
pluginLabel,
pluginDescription,
isNew,
true,
innerRefreshMenu,
),
] as const; ] as const;
} }
@ -115,9 +132,18 @@ export const mainMenuTemplate = async (
const plugin = allPlugins[id]; const plugin = allPlugins[id];
const pluginLabel = plugin?.name?.() ?? id; const pluginLabel = plugin?.name?.() ?? id;
const pluginDescription = plugin?.description?.() ?? undefined; const pluginDescription = plugin?.description?.() ?? undefined;
const isNew = plugin?.addedVersion ? satisfies(packageJson.version, plugin.addedVersion) : false; const isNew = plugin?.addedVersion
? satisfies(packageJson.version, plugin.addedVersion)
: false;
return pluginEnabledMenu(id, pluginLabel, pluginDescription, isNew, true, innerRefreshMenu); return pluginEnabledMenu(
id,
pluginLabel,
pluginDescription,
isNew,
true,
innerRefreshMenu,
);
}); });
const availableLanguages = Object.keys(languageResources); const availableLanguages = Object.keys(languageResources);
@ -229,12 +255,12 @@ export const mainMenuTemplate = async (
submenu: [ submenu: [
...((config.get('options.themes')?.length ?? 0) === 0 ...((config.get('options.themes')?.length ?? 0) === 0
? [ ? [
{ {
label: t( label: t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme', 'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
), ),
} },
] ]
: []), : []),
...(config.get('options.themes')?.map((theme: string) => ({ ...(config.get('options.themes')?.map((theme: string) => ({
type: 'normal' as const, type: 'normal' as const,
@ -251,16 +277,25 @@ export const mainMenuTemplate = async (
{ theme }, { theme },
), ),
buttons: [ buttons: [
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.cancel'), t(
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.remove'), 'main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.cancel',
),
t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.remove',
),
], ],
}); });
if (response === 1) { if (response === 1) {
config.set('options.themes', config.get('options.themes')?.filter((t) => t !== theme) ?? []); config.set(
'options.themes',
config
.get('options.themes')
?.filter((t) => t !== theme) ?? [],
);
innerRefreshMenu(); innerRefreshMenu();
} }
} },
})) ?? []), })) ?? []),
{ type: 'separator' }, { type: 'separator' },
{ {
@ -306,40 +341,40 @@ export const mainMenuTemplate = async (
}, },
...((is.windows() || is.linux() ...((is.windows() || is.linux()
? [ ? [
{ {
label: t('main.menu.options.submenu.hide-menu.label'), label: t('main.menu.options.submenu.hide-menu.label'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.hideMenu'), checked: config.get('options.hideMenu'),
click(item) { click(item) {
config.setMenuOption('options.hideMenu', item.checked); config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) { if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, { dialog.showMessageBox(win, {
type: 'info', type: 'info',
title: t( title: t(
'main.menu.options.submenu.hide-menu.dialog.title', 'main.menu.options.submenu.hide-menu.dialog.title',
), ),
message: t( message: t(
'main.menu.options.submenu.hide-menu.dialog.message', 'main.menu.options.submenu.hide-menu.dialog.message',
), ),
}); });
} }
},
}, },
}, ]
]
: []) satisfies Electron.MenuItemConstructorOptions[]), : []) satisfies Electron.MenuItemConstructorOptions[]),
...((is.windows() || is.macOS() ...((is.windows() || is.macOS()
? // Only works on Win/Mac ? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows // https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[ [
{ {
label: t('main.menu.options.submenu.start-at-login'), label: t('main.menu.options.submenu.start-at-login'),
type: 'checkbox', type: 'checkbox',
checked: config.get('options.startAtLogin'), checked: config.get('options.startAtLogin'),
click(item) { click(item) {
config.setMenuOption('options.startAtLogin', item.checked); config.setMenuOption('options.startAtLogin', item.checked);
},
}, },
}, ]
]
: []) satisfies Electron.MenuItemConstructorOptions[]), : []) satisfies Electron.MenuItemConstructorOptions[]),
{ {
label: t('main.menu.options.submenu.tray.label'), label: t('main.menu.options.submenu.tray.label'),
@ -493,25 +528,25 @@ export const mainMenuTemplate = async (
{ type: 'separator' }, { type: 'separator' },
is.macOS() is.macOS()
? { ? {
label: t( label: t(
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools', 'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
), ),
// Cannot use "toggleDevTools" role in macOS // Cannot use "toggleDevTools" role in macOS
click() { click() {
const { webContents } = win; const { webContents } = win;
if (webContents.isDevToolsOpened()) { if (webContents.isDevToolsOpened()) {
webContents.closeDevTools(); webContents.closeDevTools();
} else { } else {
webContents.openDevTools(); webContents.openDevTools();
} }
}, },
} }
: { : {
label: t( label: t(
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools', 'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
), ),
role: 'toggleDevTools', role: 'toggleDevTools',
}, },
{ {
label: t( label: t(
'main.menu.options.submenu.advanced-options.submenu.edit-config-json', 'main.menu.options.submenu.advanced-options.submenu.edit-config-json',

View File

@ -1,5 +1,7 @@
function skipAd(target: Element) { function skipAd(target: Element) {
const skipButton = target.querySelector<HTMLButtonElement>('button.ytp-ad-skip-button-modern'); const skipButton = target.querySelector<HTMLButtonElement>(
'button.ytp-ad-skip-button-modern',
);
if (skipButton) { if (skipButton) {
skipButton.click(); skipButton.click();
} }
@ -17,7 +19,7 @@ function speedUpAndMute(player: Element, isAdShowing: boolean) {
} }
} }
export const loadAdSpeedup = async () => { export const loadAdSpeedup = () => {
const player = document.querySelector<HTMLVideoElement>('#movie_player'); const player = document.querySelector<HTMLVideoElement>('#movie_player');
if (!player) return; if (!player) return;
@ -53,4 +55,4 @@ export const loadAdSpeedup = async () => {
player.classList.contains('ad-interrupting'); player.classList.contains('ad-interrupting');
speedUpAndMute(player, isAdShowing); speedUpAndMute(player, isAdShowing);
skipAd(player); skipAd(player);
} };

View File

@ -79,7 +79,7 @@ export default createPlugin({
if (config.blocker === blockers.AdSpeedup) { if (config.blocker === blockers.AdSpeedup) {
await loadAdSpeedup(); await loadAdSpeedup();
} }
} },
}, },
backend: { backend: {
mainWindow: null as BrowserWindow | null, mainWindow: null as BrowserWindow | null,

View File

@ -104,21 +104,28 @@ export default createPlugin<
buttons.splice(i, 1); buttons.splice(i, 1);
i--; i--;
} else { } else {
(buttons[i].children[0].children[0] as HTMLElement).style.setProperty( (
buttons[i].children[0].children[0] as HTMLElement
).style.setProperty(
'-webkit-mask-size', '-webkit-mask-size',
`100% ${100 - ((count / listsLength) * 100)}%`, `100% ${100 - (count / listsLength) * 100}%`,
); );
} }
i++; i++;
} }
} }
const menuParent = document.querySelector('#action-buttons')?.parentElement; const menuParent =
document.querySelector('#action-buttons')?.parentElement;
if (menuParent && !document.querySelector('.like-menu')) { if (menuParent && !document.querySelector('.like-menu')) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.id = 'ytmd-album-action-buttons'; menu.id = 'ytmd-album-action-buttons';
menu.className = 'action-buttons style-scope ytmusic-responsive-header-renderer'; menu.className =
'action-buttons style-scope ytmusic-responsive-header-renderer';
menuParent.insertBefore(menu, menuParent.children[menuParent.children.length - 1]); menuParent.insertBefore(
menu,
menuParent.children[menuParent.children.length - 1],
);
for (const button of buttons) { for (const button of buttons) {
menu.appendChild(button); menu.appendChild(button);
button.addEventListener('click', this.loadFullList); button.addEventListener('click', this.loadFullList);

View File

@ -25,7 +25,12 @@ export default createPlugin<
sidebarSmall: HTMLElement | null; sidebarSmall: HTMLElement | null;
ytmusicAppLayout: HTMLElement | null; ytmusicAppLayout: HTMLElement | null;
getMixedColor(color: string, key: string, alpha?: number, ratioMultiply?: number): string; getMixedColor(
color: string,
key: string,
alpha?: number,
ratioMultiply?: number,
): string;
updateColor(): void; updateColor(): void;
}, },
{ {
@ -91,7 +96,10 @@ export default createPlugin<
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout'); this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const config = await getConfig(); const config = await getConfig();
document.documentElement.style.setProperty(RATIO_KEY, `${~~(config.ratio * 100)}%`); document.documentElement.style.setProperty(
RATIO_KEY,
`${~~(config.ratio * 100)}%`,
);
}, },
onPlayerApiReady(playerApi) { onPlayerApiReady(playerApi) {
const fastAverageColor = new FastAverageColor(); const fastAverageColor = new FastAverageColor();
@ -100,10 +108,12 @@ export default createPlugin<
if (event.detail.name !== 'dataloaded') return; if (event.detail.name !== 'dataloaded') return;
const playerResponse = playerApi.getPlayerResponse(); const playerResponse = playerApi.getPlayerResponse();
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); const thumbnail =
playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
if (!thumbnail) return; if (!thumbnail) return;
const albumColor = await fastAverageColor.getColorAsync(thumbnail.url) const albumColor = await fastAverageColor
.getColorAsync(thumbnail.url)
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
return null; return null;
@ -120,8 +130,14 @@ export default createPlugin<
this.darkColor = this.darkColor?.darken(0.05); this.darkColor = this.darkColor?.darken(0.05);
} }
document.documentElement.style.setProperty(COLOR_KEY, `${~~this.color.red()}, ${~~this.color.green()}, ${~~this.color.blue()}`); document.documentElement.style.setProperty(
document.documentElement.style.setProperty(DARK_COLOR_KEY, `${~~this.darkColor.red()}, ${~~this.darkColor.green()}, ${~~this.darkColor.blue()}`); COLOR_KEY,
`${~~this.color.red()}, ${~~this.color.green()}, ${~~this.color.blue()}`,
);
document.documentElement.style.setProperty(
DARK_COLOR_KEY,
`${~~this.darkColor.red()}, ${~~this.darkColor.green()}, ${~~this.darkColor.blue()}`,
);
} else { } else {
document.documentElement.style.setProperty(COLOR_KEY, '0, 0, 0'); document.documentElement.style.setProperty(COLOR_KEY, '0, 0, 0');
document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0'); document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0');
@ -131,7 +147,10 @@ export default createPlugin<
}); });
}, },
onConfigChange(config) { onConfigChange(config) {
document.documentElement.style.setProperty(RATIO_KEY, `${~~(config.ratio * 100)}%`); document.documentElement.style.setProperty(
RATIO_KEY,
`${~~(config.ratio * 100)}%`,
);
}, },
getMixedColor(color: string, key: string, alpha = 1, ratioMultiply) { getMixedColor(color: string, key: string, alpha = 1, ratioMultiply) {
const keyColor = `rgba(var(${key}), ${alpha})`; const keyColor = `rgba(var(${key}), ${alpha})`;
@ -181,11 +200,23 @@ export default createPlugin<
'--yt-spec-black-1-alpha-95': 'rgba(40,40,40,0.95)', '--yt-spec-black-1-alpha-95': 'rgba(40,40,40,0.95)',
}; };
Object.entries(variableMap).map(([variable, color]) => { Object.entries(variableMap).map(([variable, color]) => {
document.documentElement.style.setProperty(variable, this.getMixedColor(color, COLOR_KEY), 'important'); document.documentElement.style.setProperty(
variable,
this.getMixedColor(color, COLOR_KEY),
'important',
);
}); });
document.body.style.setProperty('background', this.getMixedColor('#030303', COLOR_KEY), 'important'); document.body.style.setProperty(
document.documentElement.style.setProperty('--ytmusic-background', this.getMixedColor('#030303', DARK_COLOR_KEY), 'important'); 'background',
this.getMixedColor('#030303', COLOR_KEY),
'important',
);
document.documentElement.style.setProperty(
'--ytmusic-background',
this.getMixedColor('#030303', DARK_COLOR_KEY),
'important',
);
}, },
}, },
}); });

View File

@ -53,10 +53,16 @@ export default createPlugin({
const songImage = document.querySelector<HTMLImageElement>('#song-image'); const songImage = document.querySelector<HTMLImageElement>('#song-image');
const songVideo = document.querySelector<HTMLDivElement>('#song-video'); const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const image = songImage?.querySelector<HTMLImageElement>('yt-img-shadow > img'); const image = songImage?.querySelector<HTMLImageElement>(
const video = await waitForElement<HTMLVideoElement>('.html5-video-container > video'); 'yt-img-shadow > img',
);
const video = await waitForElement<HTMLVideoElement>(
'.html5-video-container > video',
);
const videoWrapper = document.querySelector('#song-video > .player-wrapper'); const videoWrapper = document.querySelector(
'#song-video > .player-wrapper',
);
const injectBlurImage = () => { const injectBlurImage = () => {
if (!songImage || !image) return null; if (!songImage || !image) return null;
@ -95,7 +101,9 @@ export default createPlugin({
const blurCanvas = document.createElement('canvas'); const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas'); blurCanvas.classList.add('html5-blur-canvas');
const context = blurCanvas.getContext('2d', { willReadFrequently: true }); const context = blurCanvas.getContext('2d', {
willReadFrequently: true,
});
/* effect */ /* effect */
let lastEffectWorkId: number | null = null; let lastEffectWorkId: number | null = null;
@ -109,14 +117,18 @@ export default createPlugin({
if (!context) return; if (!context) return;
const width = this.qualityRatio; const width = this.qualityRatio;
let height = Math.max(Math.floor((blurCanvas.height / blurCanvas.width) * width), 1,); let height = Math.max(
Math.floor((blurCanvas.height / blurCanvas.width) * width),
1,
);
if (!Number.isFinite(height)) height = width; if (!Number.isFinite(height)) height = width;
if (!height) return; if (!height) return;
context.globalAlpha = 1; context.globalAlpha = 1;
if (lastImageData) { if (lastImageData) {
const frameOffset = (1 / this.buffer) * (1000 / this.interpolationTime); const frameOffset =
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 (1 / this.buffer) * (1000 / this.interpolationTime);
context.globalAlpha = 1 - frameOffset * 2; // because of alpha value must be < 1
context.putImageData(lastImageData, 0, 0); context.putImageData(lastImageData, 0, 0);
context.globalAlpha = frameOffset; context.globalAlpha = frameOffset;
} }
@ -137,7 +149,9 @@ export default createPlugin({
if (newWidth === 0 || newHeight === 0) return; if (newWidth === 0 || newHeight === 0) return;
blurCanvas.width = this.qualityRatio; blurCanvas.width = this.qualityRatio;
blurCanvas.height = Math.floor((newHeight / newWidth) * this.qualityRatio); blurCanvas.height = Math.floor(
(newHeight / newWidth) * this.qualityRatio,
);
if (this.isFullscreen) blurCanvas.classList.add('fullscreen'); if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
else blurCanvas.classList.remove('fullscreen'); else blurCanvas.classList.remove('fullscreen');
@ -151,7 +165,10 @@ export default createPlugin({
/* hooking */ /* hooking */
let canvasInterval: NodeJS.Timeout | null = null; let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
const onPause = () => { const onPause = () => {
if (canvasInterval) clearInterval(canvasInterval); if (canvasInterval) clearInterval(canvasInterval);
@ -159,7 +176,10 @@ export default createPlugin({
}; };
const onPlay = () => { const onPlay = () => {
if (canvasInterval) clearInterval(canvasInterval); if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
}; };
songVideo.addEventListener('pause', onPause); songVideo.addEventListener('pause', onPause);
songVideo.addEventListener('play', onPlay); songVideo.addEventListener('play', onPlay);
@ -198,11 +218,20 @@ export default createPlugin({
if (isPageOpen) { if (isPageOpen) {
const isVideo = isVideoMode(); const isVideo = isVideoMode();
if (!force) { if (!force) {
if (this.lastMediaType === 'video' && this.lastVideoSource === video?.src) return false; if (
if (this.lastMediaType === 'image' && this.lastImageSource === image?.src) return false; this.lastMediaType === 'video' &&
this.lastVideoSource === video?.src
)
return false;
if (
this.lastMediaType === 'image' &&
this.lastImageSource === image?.src
)
return false;
} }
this.unregister?.(); this.unregister?.();
this.unregister = (isVideo ? injectBlurVideo() : injectBlurImage()) ?? null; this.unregister =
(isVideo ? injectBlurVideo() : injectBlurImage()) ?? null;
} else { } else {
this.unregister?.(); this.unregister?.();
this.unregister = null; this.unregister = null;

View File

@ -1,14 +1,24 @@
import { t } from "@/i18n"; import { MenuItemConstructorOptions } from 'electron';
import { MenuContext } from "@/types/contexts";
import { MenuItemConstructorOptions } from "electron"; import { t } from '@/i18n';
import { AmbientModePluginConfig } from "./types"; import { MenuContext } from '@/types/contexts';
import { AmbientModePluginConfig } from './types';
export interface menuParameters { export interface menuParameters {
getConfig: () => AmbientModePluginConfig | Promise<AmbientModePluginConfig>; getConfig: () => AmbientModePluginConfig | Promise<AmbientModePluginConfig>;
setConfig: (conf: Partial<Omit<AmbientModePluginConfig, "enabled">>) => void | Promise<void>; setConfig: (
conf: Partial<Omit<AmbientModePluginConfig, 'enabled'>>,
) => void | Promise<void>;
} }
export const menu: (ctx: MenuContext<AmbientModePluginConfig>) => MenuItemConstructorOptions[] | Promise<MenuItemConstructorOptions[]> = async ({ getConfig, setConfig }: menuParameters) => { export const menu: (
ctx: MenuContext<AmbientModePluginConfig>,
) =>
| MenuItemConstructorOptions[]
| Promise<MenuItemConstructorOptions[]> = async ({
getConfig,
setConfig,
}: menuParameters) => {
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000]; const qualityList = [10, 25, 50, 100, 200, 500, 1000];
const sizeList = [100, 110, 125, 150, 175, 200, 300]; const sizeList = [100, 110, 125, 150, 175, 200, 300];
@ -107,4 +117,4 @@ export const menu: (ctx: MenuContext<AmbientModePluginConfig>) => MenuItemConstr
}, },
}, },
]; ];
} };

View File

@ -7,4 +7,4 @@ export type AmbientModePluginConfig = {
size: number; size: number;
opacity: number; opacity: number;
fullscreen: boolean; fullscreen: boolean;
}; };

View File

@ -0,0 +1 @@
export * from './main';

View File

@ -0,0 +1,103 @@
import { jwt } from 'hono/jwt';
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
import { cors } from 'hono/cors';
import { swaggerUI } from '@hono/swagger-ui';
import { serve } from '@hono/node-server';
import registerCallback from '@/providers/song-info';
import { createBackend } from '@/utils';
import { JWTPayloadSchema } from './scheme';
import { registerAuth, registerControl } from './routes';
import type { APIServerConfig } from '../config';
import type { BackendType } from './types';
export const backend = createBackend<BackendType, APIServerConfig>({
async start(ctx) {
const config = await ctx.getConfig();
this.init(ctx);
registerCallback((songInfo) => {
this.songInfo = songInfo;
});
this.run(config.hostname, config.port);
},
stop() {
this.end();
},
onConfigChange(config) {
if (
this.oldConfig?.hostname === config.hostname &&
this.oldConfig?.port === config.port
) {
this.oldConfig = config;
return;
}
this.end();
this.run(config.hostname, config.port);
this.oldConfig = config;
},
// Custom
async init(ctx) {
const config = await ctx.getConfig();
this.app = new Hono();
this.app.use('*', cors());
// middlewares
this.app.use(
'/api/*',
jwt({
secret: config.secret,
}),
);
this.app.use('/api/*', async (ctx, next) => {
const result = await JWTPayloadSchema.spa(await ctx.get('jwtPayload'));
const isAuthorized =
result.success && config.authorizedClients.includes(result.data.id);
if (!isAuthorized) {
ctx.status(401);
return ctx.body('Unauthorized');
}
return await next();
});
// routes
registerControl(this.app, ctx, () => this.songInfo);
registerAuth(this.app, ctx);
// swagger
this.app.doc('/doc', {
openapi: '3.1.0',
info: {
version: '1.0.0',
title: 'Youtube Music API Server',
},
});
this.app.get('/swagger', swaggerUI({ url: '/doc' }));
},
run(hostname, port) {
if (!this.app) return;
try {
this.server = serve({
fetch: this.app.fetch.bind(this.app),
port,
hostname,
});
} catch (err) {
console.error(err);
}
},
end() {
this.server?.close();
this.server = undefined;
},
});

View File

@ -0,0 +1,94 @@
import { createRoute, z } from '@hono/zod-openapi';
import { dialog } from 'electron';
import { sign } from 'hono/jwt';
import { getConnInfo } from '@hono/node-server/conninfo';
import { t } from '@/i18n';
import { APIServerConfig } from '../../config';
import { JWTPayload } from '../scheme';
import type { HonoApp } from '../types';
import type { BackendContext } from '@/types/contexts';
const routes = {
request: createRoute({
method: 'post',
path: '/auth/{id}',
summary: '',
description: '',
request: {
params: z.object({
id: z.string(),
}),
},
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({
accessToken: z.string(),
}),
},
},
},
403: {
description: 'Forbidden',
},
},
}),
};
export const register = (
app: HonoApp,
{ getConfig, setConfig }: BackendContext<APIServerConfig>,
) => {
app.openapi(routes.request, async (ctx) => {
const config = await getConfig();
const { id } = ctx.req.param();
if (config.authorizedClients.includes(id)) {
// SKIP CHECK
} else if (config.authStrategy === 'AUTH_AT_FIRST') {
const result = await dialog.showMessageBox({
title: t('plugins.api-server.dialog.request.title'),
message: t('plugins.api-server.dialog.request.message', {
origin: getConnInfo(ctx).remote.address,
id,
}),
buttons: [
t('plugins.api-server.dialog.request.buttons.allow'),
t('plugins.api-server.dialog.request.deny'),
],
defaultId: 1,
cancelId: 1,
});
if (result.response === 1) {
ctx.status(403);
return ctx.body(null);
}
} else if (config.authStrategy === 'NONE') {
// SKIP CHECK
}
setConfig({
authorizedClients: [...config.authorizedClients, id],
});
const token = await sign(
{
id,
iat: ~~(Date.now() / 1000),
} satisfies JWTPayload,
config.secret,
);
ctx.status(200);
return ctx.json({
accessToken: token,
});
});
};

View File

@ -0,0 +1,482 @@
import { createRoute, z } from '@hono/zod-openapi';
import { ipcMain } from 'electron';
import getSongControls from '@/providers/song-controls';
import {
AuthHeadersSchema,
type ResponseSongInfo,
SongInfoSchema,
GoForwardScheme,
GoBackSchema,
SwitchRepeatSchema,
SetVolumeSchema,
SetFullscreenSchema,
} from '../scheme';
import type { SongInfo } from '@/providers/song-info';
import type { BackendContext } from '@/types/contexts';
import type { APIServerConfig } from '../../config';
import type { HonoApp } from '../types';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
const API_VERSION = 'v1';
const routes = {
previous: createRoute({
method: 'post',
path: `/api/${API_VERSION}/previous`,
summary: 'play previous song',
description: 'Plays the previous song in the queue',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
next: createRoute({
method: 'post',
path: `/api/${API_VERSION}/next`,
summary: 'play next song',
description: 'Plays the next song in the queue',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
play: createRoute({
method: 'post',
path: `/api/${API_VERSION}/play`,
summary: 'Play',
description: 'Change the state of the player to play',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
pause: createRoute({
method: 'post',
path: `/api/${API_VERSION}/pause`,
summary: 'Pause',
description: 'Change the state of the player to pause',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
togglePlay: createRoute({
method: 'post',
path: `/api/${API_VERSION}/toggle-play`,
summary: 'Toggle play/pause',
description:
'Change the state of the player to play if paused, or pause if playing',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
like: createRoute({
method: 'post',
path: `/api/${API_VERSION}/like`,
summary: 'like song',
description: 'Set the current song as liked',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
dislike: createRoute({
method: 'post',
path: `/api/${API_VERSION}/dislike`,
summary: 'dislike song',
description: 'Set the current song as disliked',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
goBack: createRoute({
method: 'post',
path: `/api/${API_VERSION}/go-back`,
summary: 'go back',
description: 'Move the current song back by a number of seconds',
request: {
headers: AuthHeadersSchema,
body: {
description: 'seconds to go back',
content: {
'application/json': {
schema: GoBackSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
goForward: createRoute({
method: 'post',
path: `/api/${API_VERSION}/go-forward`,
summary: 'go forward',
description: 'Move the current song forward by a number of seconds',
request: {
headers: AuthHeadersSchema,
body: {
description: 'seconds to go forward',
content: {
'application/json': {
schema: GoForwardScheme,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
shuffle: createRoute({
method: 'post',
path: `/api/${API_VERSION}/shuffle`,
summary: 'shuffle',
description: 'Shuffle the queue',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
switchRepeat: createRoute({
method: 'post',
path: `/api/${API_VERSION}/switch-repeat`,
summary: 'switch repeat',
description: 'Switch the repeat mode',
request: {
headers: AuthHeadersSchema,
body: {
description: 'number of times to click the repeat button',
content: {
'application/json': {
schema: SwitchRepeatSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
setVolume: createRoute({
method: 'post',
path: `/api/${API_VERSION}/volume`,
summary: 'set volume',
description: 'Set the volume of the player',
request: {
headers: AuthHeadersSchema,
body: {
description: 'volume to set',
content: {
'application/json': {
schema: SetVolumeSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
setFullscreen: createRoute({
method: 'post',
path: `/api/${API_VERSION}/fullscreen`,
summary: 'set fullscreen',
description: 'Set the fullscreen state of the player',
request: {
headers: AuthHeadersSchema,
body: {
description: 'fullscreen state',
content: {
'application/json': {
schema: SetFullscreenSchema,
},
},
},
},
responses: {
204: {
description: 'Success',
},
},
}),
toggleMute: createRoute({
method: 'post',
path: `/api/${API_VERSION}/toggle-mute`,
summary: 'toggle mute',
description: 'Toggle the mute state of the player',
request: {
headers: AuthHeadersSchema,
},
responses: {
204: {
description: 'Success',
},
},
}),
getFullscreenState: createRoute({
method: 'get',
path: `/api/${API_VERSION}/fullscreen`,
summary: 'get fullscreen state',
description: 'Get the current fullscreen state',
request: {
headers: AuthHeadersSchema,
},
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({
state: z.boolean(),
}),
},
},
},
},
}),
queueInfo: createRoute({
method: 'get',
path: `/api/${API_VERSION}/queue-info`,
summary: 'get current queue info',
description: 'Get the current queue info',
request: {
headers: AuthHeadersSchema,
},
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({}),
},
},
},
204: {
description: 'No queue info',
},
},
}),
songInfo: createRoute({
method: 'get',
path: `/api/${API_VERSION}/song-info`,
summary: 'get current song info',
description: 'Get the current song info',
request: {
headers: AuthHeadersSchema,
},
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: SongInfoSchema,
},
},
},
204: {
description: 'No song info',
},
},
}),
};
export const register = (
app: HonoApp,
{ window }: BackendContext<APIServerConfig>,
songInfoGetter: () => SongInfo | undefined,
) => {
const controller = getSongControls(window);
app.openapi(routes.previous, (ctx) => {
controller.previous();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.previous, (ctx) => {
controller.previous();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.play, (ctx) => {
controller.play();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.pause, (ctx) => {
controller.pause();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.togglePlay, (ctx) => {
controller.playPause();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.like, (ctx) => {
controller.like();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.dislike, (ctx) => {
controller.dislike();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.goBack, (ctx) => {
const { seconds } = ctx.req.valid('json');
controller.goBack(seconds);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.goForward, (ctx) => {
const { seconds } = ctx.req.valid('json');
controller.goForward(seconds);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.shuffle, (ctx) => {
controller.shuffle();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.switchRepeat, (ctx) => {
const { iteration } = ctx.req.valid('json');
controller.switchRepeat(iteration);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.setVolume, (ctx) => {
const { volume } = ctx.req.valid('json');
controller.setVolume(volume);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.setFullscreen, (ctx) => {
const { state } = ctx.req.valid('json');
controller.setFullscreen(state);
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.toggleMute, (ctx) => {
controller.muteUnmute();
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.getFullscreenState, async (ctx) => {
const stateResponsePromise = new Promise<boolean>((resolve) => {
ipcMain.once(
'ytmd:set-fullscreen',
(_, isFullscreen: boolean | undefined) => {
return resolve(!!isFullscreen);
},
);
controller.requestFullscreenInformation();
});
const fullscreen = await stateResponsePromise;
ctx.status(200);
return ctx.json({ state: fullscreen });
});
app.openapi(routes.queueInfo, async (ctx) => {
const queueResponsePromise = new Promise<QueueResponse>((resolve) => {
ipcMain.once('ytmd:get-queue-response', (_, queue: QueueResponse) => {
return resolve(queue);
});
controller.requestQueueInformation();
});
const info = await queueResponsePromise;
if (!info) {
ctx.status(204);
return ctx.body(null);
}
ctx.status(200);
return ctx.json(info);
});
app.openapi(routes.songInfo, (ctx) => {
const info = songInfoGetter();
if (!info) {
ctx.status(204);
return ctx.body(null);
}
const body = { ...info };
delete body.image;
ctx.status(200);
return ctx.json(body satisfies ResponseSongInfo);
});
};

View File

@ -0,0 +1,2 @@
export { register as registerControl } from './control';
export { register as registerAuth } from './auth';

View File

@ -0,0 +1,13 @@
import { z } from '@hono/zod-openapi';
export const AuthHeadersSchema = z.object({
authorization: z.string().openapi({
example: 'Bearer token',
}),
});
export type JWTPayload = z.infer<typeof JWTPayloadSchema>;
export const JWTPayloadSchema = z.object({
id: z.string(),
iat: z.number(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const GoBackSchema = z.object({
seconds: z.number(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const GoForwardScheme = z.object({
seconds: z.number(),
});

View File

@ -0,0 +1,7 @@
export * from './auth';
export * from './song-info';
export * from './go-back';
export * from './go-forward';
export * from './switch-repeat';
export * from './set-volume';
export * from './set-fullscreen';

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SetFullscreenSchema = z.object({
state: z.boolean(),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SetVolumeSchema = z.object({
volume: z.number(),
});

View File

@ -0,0 +1,26 @@
import { z } from '@hono/zod-openapi';
import { MediaType } from '@/providers/song-info';
export type ResponseSongInfo = z.infer<typeof SongInfoSchema>;
export const SongInfoSchema = z.object({
title: z.string(),
artist: z.string(),
views: z.number(),
uploadDate: z.string().optional(),
imageSrc: z.string().nullable().optional(),
isPaused: z.boolean().optional(),
songDuration: z.number(),
elapsedSeconds: z.number().optional(),
url: z.string().optional(),
album: z.string().nullable().optional(),
videoId: z.string(),
playlistId: z.string().optional(),
mediaType: z.enum([
MediaType.Audio,
MediaType.OriginalMusicVideo,
MediaType.UserGeneratedContent,
MediaType.PodcastEpisode,
MediaType.OtherVideo,
]),
});

View File

@ -0,0 +1,5 @@
import { z } from '@hono/zod-openapi';
export const SwitchRepeatSchema = z.object({
iteration: z.number(),
});

View File

@ -0,0 +1,18 @@
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
import { serve } from '@hono/node-server';
import type { BackendContext } from '@/types/contexts';
import type { SongInfo } from '@/providers/song-info';
import type { APIServerConfig } from '../config';
export type HonoApp = Hono;
export type BackendType = {
app?: HonoApp;
server?: ReturnType<typeof serve>;
oldConfig?: APIServerConfig;
songInfo?: SongInfo;
init: (ctx: BackendContext<APIServerConfig>) => void;
run: (hostname: string, port: number) => void;
end: () => void;
};

View File

@ -0,0 +1,19 @@
export interface APIServerConfig {
enabled: boolean;
hostname: string;
port: number;
authStrategy: 'AUTH_AT_FIRST' | 'NONE';
secret: string;
authorizedClients: string[];
}
export const defaultAPIServerConfig: APIServerConfig = {
enabled: true,
hostname: '0.0.0.0',
port: 26538,
authStrategy: 'AUTH_AT_FIRST',
secret: Date.now().toString(36),
authorizedClients: [],
};

View File

@ -0,0 +1,17 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import { defaultAPIServerConfig } from './config';
import { onMenu } from './menu';
import { backend } from './backend';
export default createPlugin({
name: () => t('plugins.api-server.name'),
description: () => t('plugins.api-server.description'),
restartNeeded: false,
config: defaultAPIServerConfig,
addedVersion: '3.6.X',
menu: onMenu,
backend,
});

View File

@ -0,0 +1,93 @@
import prompt from 'custom-electron-prompt';
import { t } from '@/i18n';
import promptOptions from '@/providers/prompt-options';
import { APIServerConfig, defaultAPIServerConfig } from './config';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';
export const onMenu = async ({
getConfig,
setConfig,
window,
}: MenuContext<APIServerConfig>): Promise<MenuTemplate> => {
const config = await getConfig();
return [
{
label: t('plugins.api-server.menu.hostname.label'),
type: 'normal',
async click() {
const config = await getConfig();
const newHostname =
(await prompt(
{
title: t('plugins.api-server.prompt.hostname.title'),
label: t('plugins.api-server.prompt.hostname.label'),
value: config.hostname,
type: 'input',
width: 380,
...promptOptions(),
},
window,
)) ??
config.hostname ??
defaultAPIServerConfig.hostname;
setConfig({ ...config, hostname: newHostname });
},
},
{
label: t('plugins.api-server.menu.port.label'),
type: 'normal',
async click() {
const config = await getConfig();
const newPort =
(await prompt(
{
title: t('plugins.api-server.prompt.port.title'),
label: t('plugins.api-server.prompt.port.label'),
value: config.port,
type: 'counter',
counterOptions: { minimum: 0, maximum: 65565 },
width: 380,
...promptOptions(),
},
window,
)) ??
config.port ??
defaultAPIServerConfig.port;
setConfig({ ...config, port: newPort });
},
},
{
label: t('plugins.api-server.menu.auth-strategy.label'),
type: 'submenu',
submenu: [
{
label: t(
'plugins.api-server.menu.auth-strategy.submenu.auth-at-first.label',
),
type: 'radio',
checked: config.authStrategy === 'AUTH_AT_FIRST',
click() {
setConfig({ ...config, authStrategy: 'AUTH_AT_FIRST' });
},
},
{
label: t('plugins.api-server.menu.auth-strategy.submenu.none.label'),
type: 'radio',
checked: config.authStrategy === 'NONE',
click() {
setConfig({ ...config, authStrategy: 'NONE' });
},
},
],
},
];
};

View File

@ -15,7 +15,10 @@ export default createPlugin({
this.styleSheet = new CSSStyleSheet(); this.styleSheet = new CSSStyleSheet();
await this.styleSheet.replace(style); await this.styleSheet.replace(style);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.styleSheet]; document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
this.styleSheet,
];
}, },
async stop() { async stop() {
await this.styleSheet?.replace(''); await this.styleSheet?.replace('');

View File

@ -34,7 +34,7 @@ export default createPlugin<
{ {
label: t('plugins.captions-selector.menu.autoload'), label: t('plugins.captions-selector.menu.autoload'),
type: 'checkbox', type: 'checkbox',
checked: config.autoload as boolean, checked: config.autoload,
click(item) { click(item) {
setConfig({ autoload: item.checked }); setConfig({ autoload: item.checked });
}, },
@ -42,7 +42,7 @@ export default createPlugin<
{ {
label: t('plugins.captions-selector.menu.disable-captions'), label: t('plugins.captions-selector.menu.disable-captions'),
type: 'checkbox', type: 'checkbox',
checked: config.disableCaptions as boolean, checked: config.disableCaptions,
click(item) { click(item) {
setConfig({ disableCaptions: item.checked }); setConfig({ disableCaptions: item.checked });
}, },

View File

@ -64,7 +64,7 @@ interface VolumeFade {
// Main class // Main class
export class VolumeFader { export class VolumeFader {
private readonly media: HTMLMediaElement; private readonly media: HTMLMediaElement;
private readonly logger: VolumeLogger | false; private readonly logger: VolumeLogger | null;
private scale: { private scale: {
internalToVolume: (level: number) => number; internalToVolume: (level: number) => number;
volumeToInternal: (level: number) => number; volumeToInternal: (level: number) => number;
@ -100,7 +100,7 @@ export class VolumeFader {
this.logger = options.logger; this.logger = options.logger;
} else { } else {
// Set log function explicitly to false // Set log function explicitly to false
this.logger = false; this.logger = null;
} }
// Linear volume fading? // Linear volume fading?
@ -112,7 +112,7 @@ export class VolumeFader {
}; };
// Log setting // Log setting
this.logger && this.logger('Using linear fading.'); this.logger?.('Using linear fading.');
} }
// No linear, but logarithmic fading… // No linear, but logarithmic fading…
else { else {
@ -152,9 +152,8 @@ export class VolumeFader {
}; };
// Log setting if not default // Log setting if not default
options.fadeScaling && if (options.fadeScaling)
this.logger && this.logger?.(
this.logger(
'Using logarithmic fading with ' + 'Using logarithmic fading with ' +
String(10 * dynamicRange) + String(10 * dynamicRange) +
' dB dynamic range.', ' dB dynamic range.',
@ -170,8 +169,7 @@ export class VolumeFader {
this.media.volume = options.initialVolume; this.media.volume = options.initialVolume;
// Log setting // Log setting
this.logger && this.logger?.('Set initial volume to ' + String(this.media.volume) + '.');
this.logger('Set initial volume to ' + String(this.media.volume) + '.');
} }
// Fade duration given? // Fade duration given?
@ -187,7 +185,7 @@ export class VolumeFader {
this.active = false; this.active = false;
// Initialization done // Initialization done
this.logger && this.logger('Initialized for', this.media); this.logger?.('Initialized for', this.media);
} }
/** /**
@ -236,8 +234,7 @@ export class VolumeFader {
this.fadeDuration = fadeDuration; this.fadeDuration = fadeDuration;
// Log setting // Log setting
this.logger && this.logger?.('Set fade duration to ' + String(fadeDuration) + ' ms.');
this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
} else { } else {
// Abort and throw an exception // Abort and throw an exception
throw new TypeError('Positive number expected as fade duration!'); throw new TypeError('Positive number expected as fade duration!');
@ -279,7 +276,7 @@ export class VolumeFader {
this.start(); this.start();
// Log new fade // Log new fade
this.logger && this.logger('New fade started:', this.fade); this.logger?.('New fade started:', this.fade);
// Return instance for chaining // Return instance for chaining
return this; return this;
@ -313,7 +310,7 @@ export class VolumeFader {
// Compute current level on internal scale // Compute current level on internal scale
const level = const level =
(progress * (this.fade.volume.end - this.fade.volume.start)) + progress * (this.fade.volume.end - this.fade.volume.start) +
this.fade.volume.start; this.fade.volume.start;
// Map fade level to volume level and apply it to media element // Map fade level to volume level and apply it to media element
@ -323,8 +320,7 @@ export class VolumeFader {
window.requestAnimationFrame(this.updateVolume.bind(this)); window.requestAnimationFrame(this.updateVolume.bind(this));
} else { } else {
// Log end of fade // Log end of fade
this.logger && this.logger?.('Fade to ' + String(this.fade.volume.end) + ' complete.');
this.logger('Fade to ' + String(this.fade.volume.end) + ' complete.');
// Time is up, jump to target volume // Time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end); this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
@ -333,7 +329,7 @@ export class VolumeFader {
this.active = false; this.active = false;
// Done, call back (if callable) // Done, call back (if callable)
typeof this.fade.callback === 'function' && this.fade.callback(); if (typeof this.fade.callback === 'function') this.fade.callback();
// Clear fade // Clear fade
this.fade = undefined; this.fade = undefined;
@ -382,7 +378,7 @@ export class VolumeFader {
input = Math.log10(input); input = Math.log10(input);
// Scale minus something × 10 dB to 0…1 (clipping at 0) // Scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + (input / dynamicRange), 0); return Math.max(1 + input / dynamicRange, 0);
} }
} }

View File

@ -191,7 +191,7 @@ export default createPlugin<
let waitForTransition: Promise<unknown>; let waitForTransition: Promise<unknown>;
const getStreamURL = async (videoID: string): Promise<string> => const getStreamURL = async (videoID: string): Promise<string> =>
this.ipc?.invoke('audio-url', videoID); this.ipc?.invoke('audio-url', videoID) as Promise<string>;
const getVideoIDFromURL = (url: string) => const getVideoIDFromURL = (url: string) =>
new URLSearchParams(url.split('?')?.at(-1)).get('v'); new URLSearchParams(url.split('?')?.at(-1)).get('v');

View File

@ -202,9 +202,9 @@ export const backend = createBackend<
} }
} else if (!config.hideDurationLeft) { } else if (!config.hideDurationLeft) {
// Add the start and end time of the song // Add the start and end time of the song
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); const songStartTime = Date.now() - (songInfo.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime; activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + (songInfo.songDuration * 1000); activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
} }
info.rpc.user?.setActivity(activityInfo).catch(console.error); info.rpc.user?.setActivity(activityInfo).catch(console.error);

View File

@ -183,12 +183,18 @@ function downloadSongOnFinishSetup({
config.downloadOnFinish.mode === 'seconds' && config.downloadOnFinish.mode === 'seconds' &&
duration - time <= config.downloadOnFinish.seconds duration - time <= config.downloadOnFinish.seconds
) { ) {
downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); downloadSong(
currentUrl,
config.downloadOnFinish.folder ?? config.downloadFolder,
);
} else if ( } else if (
config.downloadOnFinish.mode === 'percent' && config.downloadOnFinish.mode === 'percent' &&
time >= duration * (config.downloadOnFinish.percent / 100) time >= duration * (config.downloadOnFinish.percent / 100)
) { ) {
downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); downloadSong(
currentUrl,
config.downloadOnFinish.folder ?? config.downloadFolder,
);
} }
} }
@ -438,7 +444,7 @@ async function iterableStreamToProcessedUint8Array(
}), }),
ratio, ratio,
); );
increasePlaylistProgress(0.15 + (ratio * 0.85)); increasePlaylistProgress(0.15 + ratio * 0.85);
}); });
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`; const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
@ -566,7 +572,13 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
return; return;
} }
if (!playlist || !playlist.items || playlist.items.length === 0 || !playlist.header || !('title' in playlist.header)) { if (
!playlist ||
!playlist.items ||
playlist.items.length === 0 ||
!playlist.header ||
!('title' in playlist.header)
) {
sendError( sendError(
new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')), new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')),
); );
@ -660,7 +672,7 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
const increaseProgress = (itemPercentage: number) => { const increaseProgress = (itemPercentage: number) => {
const currentProgress = (counter - 1) / (items.length ?? 1); const currentProgress = (counter - 1) / (items.length ?? 1);
const newProgress = currentProgress + (progressStep * itemPercentage); const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress); win.setProgressBar(newProgress);
}; };

View File

@ -35,7 +35,10 @@ export const onMenu = async ({
click(item) { click(item) {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
enabled: item.checked, enabled: item.checked,
}, },
}); });
@ -49,14 +52,19 @@ export const onMenu = async ({
click() { click() {
const result = dialog.showOpenDialogSync({ const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.downloadOnFinish?.folder ?? config.downloadFolder), defaultPath: getFolder(
config.downloadOnFinish?.folder ?? config.downloadFolder,
),
}); });
if (result) { if (result) {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
folder: result[0], folder: result[0],
} },
}); });
} }
}, },
@ -76,7 +84,10 @@ export const onMenu = async ({
click() { click() {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
mode: 'seconds', mode: 'seconds',
}, },
}); });
@ -91,7 +102,10 @@ export const onMenu = async ({
click() { click() {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
mode: 'percent', mode: 'percent',
}, },
}); });
@ -120,7 +134,9 @@ export const onMenu = async ({
min: '0', min: '0',
step: '1', step: '1',
}, },
value: config.downloadOnFinish?.seconds ?? defaultConfig.downloadOnFinish!.seconds, value:
config.downloadOnFinish?.seconds ??
defaultConfig.downloadOnFinish!.seconds,
}, },
{ {
label: t( label: t(
@ -133,7 +149,9 @@ export const onMenu = async ({
max: '100', max: '100',
step: '1', step: '1',
}, },
value: config.downloadOnFinish?.percent ?? defaultConfig.downloadOnFinish!.percent, value:
config.downloadOnFinish?.percent ??
defaultConfig.downloadOnFinish!.percent,
}, },
], ],
...promptOptions(), ...promptOptions(),
@ -147,7 +165,10 @@ export const onMenu = async ({
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
seconds: Number(res[0]), seconds: Number(res[0]),
percent: Number(res[1]), percent: Number(res[1]),
}, },

View File

@ -39,7 +39,9 @@ const menuObserver = new MutationObserver(() => {
if (!menuUrl?.includes('watch?')) { if (!menuUrl?.includes('watch?')) {
menuUrl = undefined; menuUrl = undefined;
// check for podcast // check for podcast
for (const it of document.querySelectorAll('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')) { for (const it of document.querySelectorAll(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
menuUrl = it.getAttribute('href')!; menuUrl = it.getAttribute('href')!;
break; break;
@ -72,7 +74,9 @@ export const onRendererLoad = ({
?.getAttribute('href'); ?.getAttribute('href');
if (!videoUrl && songMenu) { if (!videoUrl && songMenu) {
for (const it of songMenu.querySelectorAll('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')) { for (const it of songMenu.querySelectorAll(
'ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
videoUrl = it.getAttribute('href'); videoUrl = it.getAttribute('href');
break; break;
@ -86,7 +90,8 @@ export const onRendererLoad = ({
} }
if (videoUrl.startsWith('podcast/')) { if (videoUrl.startsWith('podcast/')) {
videoUrl = defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v='); videoUrl =
defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v=');
} }
if (videoUrl.includes('?playlist=')) { if (videoUrl.includes('?playlist=')) {

View File

@ -4,24 +4,12 @@ export interface InAppMenuConfig {
} }
export const defaultInAppMenuConfig: InAppMenuConfig = { export const defaultInAppMenuConfig: InAppMenuConfig = {
enabled: enabled:
( ((typeof window !== 'undefined' &&
( !window.navigator?.userAgent?.toLowerCase().includes('mac')) ||
typeof window !== 'undefined' && (typeof global !== 'undefined' &&
!window.navigator?.userAgent?.toLowerCase().includes('mac') global.process?.platform !== 'darwin')) &&
) || ((typeof window !== 'undefined' &&
( !window.navigator?.userAgent?.toLowerCase().includes('linux')) ||
typeof global !== 'undefined' && (typeof global !== 'undefined' && global.process?.platform !== 'linux')),
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

@ -1,6 +1,13 @@
import { register } from 'electron-localshortcut'; import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; import {
BrowserWindow,
Menu,
MenuItem,
ipcMain,
nativeImage,
WebContents,
} from 'electron';
import type { BackendContext } from '@/types/contexts'; import type { BackendContext } from '@/types/contexts';
import type { InAppMenuConfig } from './constants'; import type { InAppMenuConfig } from './constants';
@ -50,11 +57,13 @@ export const onMainLoad = ({
ipcMain.handle('ytmd:menu-event', (event, commandId: number) => { ipcMain.handle('ytmd:menu-event', (event, commandId: number) => {
const target = getMenuItemById(commandId); const target = getMenuItemById(commandId);
if (target) if (target)
target.click( (
undefined, target.click as (
BrowserWindow.fromWebContents(event.sender), args0: unknown,
event.sender, args1: BrowserWindow | null,
); args3: WebContents,
) => void
)(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
}); });
handle('get-menu-by-id', (commandId: number) => { handle('get-menu-by-id', (commandId: number) => {

View File

@ -16,8 +16,9 @@ const isMacOS = navigator.userAgent.includes('Macintosh');
const isNotWindowsOrMacOS = const isNotWindowsOrMacOS =
!navigator.userAgent.includes('Windows') && !isMacOS; !navigator.userAgent.includes('Windows') && !isMacOS;
const [config, setConfig] = createSignal<InAppMenuConfig>(
const [config, setConfig] = createSignal<InAppMenuConfig>(defaultInAppMenuConfig); defaultInAppMenuConfig,
);
export const onRendererLoad = async ({ export const onRendererLoad = async ({
getConfig, getConfig,
ipc, ipc,
@ -29,14 +30,19 @@ export const onRendererLoad = async ({
stylesheet.replaceSync(scrollStyle); stylesheet.replaceSync(scrollStyle);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
render(() => ( render(
<TitleBar () => (
ipc={ipc} <TitleBar
isMacOS={isMacOS} ipc={ipc}
enableController={isNotWindowsOrMacOS && !config().hideDOMWindowControls} isMacOS={isMacOS}
initialCollapsed={window.mainConfig.get('options.hideMenu')} enableController={
/> isNotWindowsOrMacOS && !config().hideDOMWindowControls
), document.body); }
initialCollapsed={window.mainConfig.get('options.hideMenu')}
/>
),
document.body,
);
}; };
export const onPlayerApiReady = () => { export const onPlayerApiReady = () => {

View File

@ -3,36 +3,38 @@ import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const iconButton = cacheNoArgs(() => css` const iconButton = cacheNoArgs(
-webkit-app-region: none; () => css`
-webkit-app-region: none;
background: transparent; background: transparent;
width: 24px; width: 24px;
height: 24px; height: 24px;
padding: 2px; padding: 2px;
border-radius: 2px; border-radius: 2px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: white; color: white;
outline: none; outline: none;
border: none; border: none;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
&:hover { &:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
&:active { &:active {
scale: 0.9; scale: 0.9;
} }
`); `,
);
type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>; type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>;
export const IconButton = (props: CollapseIconButtonProps) => { export const IconButton = (props: CollapseIconButtonProps) => {

View File

@ -3,31 +3,33 @@ import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const menuStyle = cacheNoArgs(() => css` const menuStyle = cacheNoArgs(
-webkit-app-region: none; () => css`
-webkit-app-region: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
&:active { &:active {
scale: 0.9; scale: 0.9;
} }
&[data-selected="true"] { &[data-selected='true'] {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
`); `,
);
export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & { export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & {
text?: string; text?: string;

View File

@ -2,39 +2,48 @@ import { createSignal, JSX, Show, splitProps } from 'solid-js';
import { mergeProps, Portal } from 'solid-js/web'; import { mergeProps, Portal } from 'solid-js/web';
import { css } from 'solid-styled-components'; 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 { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const panelStyle = cacheNoArgs(() => css` const panelStyle = cacheNoArgs(
position: fixed; () => css`
top: var(--offset-y, 0); position: fixed;
left: var(--offset-x, 0); top: var(--offset-y, 0);
left: var(--offset-x, 0);
max-width: var(--max-width, 100%); max-width: var(--max-width, 100%);
max-height: var(--max-height, 100%); max-height: var(--max-height, 100%);
z-index: 10000; z-index: 10000;
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
padding: 4px; padding: 4px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 8px; border-radius: 8px;
overflow: auto; overflow: auto;
background-color: color-mix( background-color: color-mix(
in srgb, in srgb,
var(--titlebar-background-color, #030303) 50%, var(--titlebar-background-color, #030303) 50%,
rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1)
); );
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2); 0 0 0 1px rgba(0, 0, 0, 0.05),
0 2px 8px rgba(0, 0, 0, 0.2);
transform-origin: var(--origin-x, 50%) var(--origin-y, 50%); transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
`); `,
);
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -42,19 +51,23 @@ const animationStyle = cacheNoArgs(() => ({
transform: scale(0.9); transform: scale(0.9);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
})); }));
export type Placement = export type Placement =
'top' | 'top'
| 'bottom' | 'bottom'
| 'left' | 'left'
| 'right' | 'right'
@ -92,9 +105,15 @@ export const Panel = (props: PanelProps) => {
size({ size({
padding: 8, padding: 8,
apply({ elements, availableWidth, availableHeight }) { apply({ elements, availableWidth, availableHeight }) {
elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`); elements.floating.style.setProperty(
elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`); '--max-width',
} `${Math.max(200, availableWidth)}px`,
);
elements.floating.style.setProperty(
'--max-height',
`${Math.max(200, availableHeight)}px`,
);
},
}), }),
flip({ fallbackStrategy: 'initialPlacement' }), flip({ fallbackStrategy: 'initialPlacement' }),
], ],
@ -103,7 +122,10 @@ export const Panel = (props: PanelProps) => {
const originX = () => { const originX = () => {
if (position.placement.includes('left')) return '100%'; if (position.placement.includes('left')) return '100%';
if (position.placement.includes('right')) return '0'; if (position.placement.includes('right')) return '0';
if (position.placement.includes('top') || position.placement.includes('bottom')) { if (
position.placement.includes('top') ||
position.placement.includes('bottom')
) {
if (position.placement.includes('start')) return '0'; if (position.placement.includes('start')) return '0';
if (position.placement.includes('end')) return '100%'; if (position.placement.includes('end')) return '100%';
} }
@ -113,7 +135,10 @@ export const Panel = (props: PanelProps) => {
const originY = () => { const originY = () => {
if (position.placement.includes('top')) return '100%'; if (position.placement.includes('top')) return '100%';
if (position.placement.includes('bottom')) return '0'; if (position.placement.includes('bottom')) return '0';
if (position.placement.includes('left') || position.placement.includes('right')) { if (
position.placement.includes('left') ||
position.placement.includes('right')
) {
if (position.placement.includes('start')) return '0'; if (position.placement.includes('start')) return '0';
if (position.placement.includes('end')) return '100%'; if (position.placement.includes('end')) return '100%';
} }

View File

@ -10,100 +10,111 @@ import { autoUpdate, offset, size } from '@floating-ui/dom';
import { Panel } from './Panel'; import { Panel } from './Panel';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const itemStyle = cacheNoArgs(() => css` const itemStyle = cacheNoArgs(
position: relative; () => css`
position: relative;
-webkit-app-region: none; -webkit-app-region: none;
min-height: 32px; min-height: 32px;
height: 32px; height: 32px;
display: grid; display: grid;
grid-template-columns: 32px 1fr auto minmax(32px, auto); grid-template-columns: 32px 1fr auto minmax(32px, auto);
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
box-sizing: border-box;
user-select: none;
-webkit-user-drag: none;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&:active {
background-color: rgba(255, 255, 255, 0.2);
}
&[data-selected="true"] {
background-color: rgba(255, 255, 255, 0.2);
}
& * {
box-sizing: border-box; box-sizing: border-box;
} user-select: none;
`); -webkit-user-drag: none;
const itemIconStyle = cacheNoArgs(() => css` transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
height: 32px;
padding: 4px;
color: white;
`);
const itemLabelStyle = cacheNoArgs(() => css` &:hover {
font-size: 12px; background-color: rgba(255, 255, 255, 0.1);
color: white; }
`);
const itemChipStyle = cacheNoArgs(() => css` &:active {
display: flex; background-color: rgba(255, 255, 255, 0.2);
justify-content: center; }
align-items: center;
min-width: 16px; &[data-selected='true'] {
height: 16px; background-color: rgba(255, 255, 255, 0.2);
padding: 0 4px; }
margin-left: 8px;
border-radius: 4px; & * {
background-color: rgba(255, 255, 255, 0.2); box-sizing: border-box;
color: #f1f1f1; }
font-size: 10px; `,
font-weight: 500; );
line-height: 1;
`);
const toolTipStyle = cacheNoArgs(() => css` const itemIconStyle = cacheNoArgs(
min-width: 32px; () => css`
width: 100%; height: 32px;
height: 100%; padding: 4px;
color: white;
`,
);
padding: 4px; const itemLabelStyle = cacheNoArgs(
() => css`
font-size: 12px;
color: white;
`,
);
max-width: calc(var(--max-width, 100%) - 8px); const itemChipStyle = cacheNoArgs(
max-height: calc(var(--max-height, 100%) - 8px); () => css`
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px; min-width: 16px;
background-color: rgba(25, 25, 25, 0.8); height: 16px;
color: #f1f1f1; padding: 0 4px;
font-size: 10px; margin-left: 8px;
`);
const popupStyle = cacheNoArgs(() => css` border-radius: 4px;
position: fixed; background-color: rgba(255, 255, 255, 0.2);
top: var(--offset-y, 0); color: #f1f1f1;
left: var(--offset-x, 0); font-size: 10px;
font-weight: 500;
line-height: 1;
`,
);
max-width: var(--max-width, 100%); const toolTipStyle = cacheNoArgs(
max-height: var(--max-height, 100%); () => css`
min-width: 32px;
width: 100%;
height: 100%;
z-index: 100000000; padding: 4px;
pointer-events: none;
`); max-width: calc(var(--max-width, 100%) - 8px);
max-height: calc(var(--max-height, 100%) - 8px);
border-radius: 4px;
background-color: rgba(25, 25, 25, 0.8);
color: #f1f1f1;
font-size: 10px;
`,
);
const popupStyle = cacheNoArgs(
() => css`
position: fixed;
top: var(--offset-y, 0);
left: var(--offset-x, 0);
max-width: var(--max-width, 100%);
max-height: var(--max-height, 100%);
z-index: 100000000;
pointer-events: none;
`,
);
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -111,14 +122,18 @@ const animationStyle = cacheNoArgs(() => ({
transform: scale(0.9); transform: scale(0.9);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
})); }));
@ -160,7 +175,11 @@ type CheckboxPanelItemProps = BasePanelItemProps & {
checked: boolean; checked: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
}; };
export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps; export type PanelItemProps =
| NormalPanelItemProps
| SubmenuItemProps
| RadioPanelItemProps
| CheckboxPanelItemProps;
export const PanelItem = (props: PanelItemProps) => { export const PanelItem = (props: PanelItemProps) => {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
const [toolTipOpen, setToolTipOpen] = createSignal(false); const [toolTipOpen, setToolTipOpen] = createSignal(false);
@ -176,17 +195,24 @@ export const PanelItem = (props: PanelItemProps) => {
offset({ mainAxis: 8 }), offset({ mainAxis: 8 }),
size({ size({
apply({ rects, elements }) { apply({ rects, elements }) {
elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`); elements.floating.style.setProperty(
} '--max-width',
`${rects.reference.width}px`,
);
},
}), }),
], ],
}); });
const handleHover = (event: MouseEvent) => { const handleHover = (event: MouseEvent) => {
setToolTipOpen(true); setToolTipOpen(true);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
setToolTipOpen(false); 'mouseleave',
}, { once: true }); () => {
setToolTipOpen(false);
},
{ once: true },
);
if (props.type === 'submenu') { if (props.type === 'submenu') {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -200,36 +226,54 @@ export const PanelItem = (props: PanelItemProps) => {
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
setTimeout(() => { 'mouseleave',
document.removeEventListener('mousemove', onMouseMove); () => {
const parents = getParents(document.elementFromPoint(mouseX, mouseY)); setTimeout(() => {
document.removeEventListener('mousemove', onMouseMove);
const parents = getParents(
document.elementFromPoint(mouseX, mouseY),
);
if (!parents.includes(child())) { if (!parents.includes(child())) {
setOpen(false); setOpen(false);
} else { } else {
const onOtherHover = (event: MouseEvent) => { const onOtherHover = (event: MouseEvent) => {
const parents = getParents(event.target as HTMLElement); const parents = getParents(event.target as HTMLElement);
const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? ''; const closestLevel =
const path = event.composedPath(); parents.find((it) => it?.dataset?.level)?.dataset.level ??
'';
const path = event.composedPath();
const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle())); const isOtherItem = path.some(
const isChild = closestLevel.startsWith(props.level.join('/')); (it) =>
it instanceof HTMLElement &&
it.classList.contains(itemStyle()),
);
const isChild = closestLevel.startsWith(
props.level.join('/'),
);
if (isOtherItem && !isChild) { if (isOtherItem && !isChild) {
setOpen(false); setOpen(false);
document.removeEventListener('mousemove', onOtherHover); document.removeEventListener('mousemove', onOtherHover);
} }
}; };
document.addEventListener('mousemove', onOtherHover); document.addEventListener('mousemove', onOtherHover);
} }
}, 225); }, 225);
}, { once: true }); },
{ once: true },
);
}, 225); }, 225);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
clearTimeout(timer); 'mouseleave',
}, { once: true }); () => {
clearTimeout(timer);
},
{ once: true },
);
} }
}; };
@ -244,7 +288,6 @@ export const PanelItem = (props: PanelItemProps) => {
} }
}; };
return ( return (
<li <li
ref={setAnchor} ref={setAnchor}
@ -253,45 +296,66 @@ export const PanelItem = (props: PanelItemProps) => {
onClick={handleClick} onClick={handleClick}
data-selected={open()} data-selected={open()}
> >
<Switch fallback={<div class={itemIconStyle()}/>}> <Switch fallback={<div class={itemIconStyle()} />}>
<Match when={props.type === 'checkbox' && props.checked}> <Match when={props.type === 'checkbox' && props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" <svg
stroke="currentColor" fill="none" class={itemIconStyle()}
stroke-linecap="round" stroke-linejoin="round"> xmlns="http://www.w3.org/2000/svg"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> viewBox="0 0 24 24"
<path d="M5 12l5 5l10 -10"/> stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l5 5l10 -10" />
</svg> </svg>
</Match> </Match>
<Match when={props.type === 'radio' && props.checked}> <Match when={props.type === 'radio' && props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" <svg
style={{ padding: '6px' }}> class={itemIconStyle()}
<path fill="currentColor" xmlns="http://www.w3.org/2000/svg"
d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/> viewBox="0 0 20 20"
style={{ padding: '6px' }}
>
<path
fill="currentColor"
d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"
/>
</svg> </svg>
</Match> </Match>
<Match when={props.type === 'radio' && !props.checked}> <Match when={props.type === 'radio' && !props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" <svg
style={{ padding: '6px' }}> class={itemIconStyle()}
<path fill="currentColor" xmlns="http://www.w3.org/2000/svg"
d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"/> viewBox="0 0 20 20"
style={{ padding: '6px' }}
>
<path
fill="currentColor"
d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z"
/>
</svg> </svg>
</Match> </Match>
</Switch> </Switch>
<span class={itemLabelStyle()}> <span class={itemLabelStyle()}>{props.name}</span>
{props.name} <Show when={props.chip} fallback={<div />}>
</span> <span class={itemChipStyle()}>{props.chip}</span>
<Show when={props.chip} fallback={<div/>}>
<span class={itemChipStyle()}>
{props.chip}
</span>
</Show> </Show>
<Show when={props.type === 'submenu'}> <Show when={props.type === 'submenu'}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" <svg
stroke="currentColor" class={itemIconStyle()}
fill="none" xmlns="http://www.w3.org/2000/svg"
stroke-linecap="round" stroke-linejoin="round"> viewBox="0 0 24 24"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke-width="1.5"
<polyline points="9 6 15 12 9 18"/> stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</svg> </svg>
<Panel <Panel
ref={setChild} ref={setChild}
@ -322,9 +386,7 @@ export const PanelItem = (props: PanelItemProps) => {
exitActiveClass={animationStyle().exitActive} exitActiveClass={animationStyle().exitActive}
> >
<Show when={toolTipOpen()}> <Show when={toolTipOpen()}>
<div class={toolTipStyle()}> <div class={toolTipStyle()}>{props.toolTip}</div>
{props.toolTip}
</div>
</Show> </Show>
</Transition> </Transition>
</div> </div>

View File

@ -1,5 +1,15 @@
import { Menu, MenuItem } from 'electron'; import { Menu, MenuItem } from 'electron';
import { createEffect, createResource, createSignal, Index, Match, onCleanup, onMount, Show, Switch } from 'solid-js'; import {
createEffect,
createResource,
createSignal,
Index,
Match,
onCleanup,
onMount,
Show,
Switch,
} from 'solid-js';
import { css } from 'solid-styled-components'; import { css } from 'solid-styled-components';
import { TransitionGroup } from 'solid-transition-group'; import { TransitionGroup } from 'solid-transition-group';
@ -14,49 +24,55 @@ import { cacheNoArgs } 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';
const titleStyle = cacheNoArgs(() => css` const titleStyle = cacheNoArgs(
-webkit-app-region: drag; () => css`
box-sizing: border-box; -webkit-app-region: drag;
box-sizing: border-box;
position: fixed; position: fixed;
top: 0; top: 0;
z-index: 10000000; z-index: 10000000;
width: 100%; width: 100%;
height: var(--menu-bar-height, 32px); height: var(--menu-bar-height, 32px);
display: flex; display: flex;
flex-flow: row; flex-flow: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
color: #f1f1f1; color: #f1f1f1;
font-size: 12px; font-size: 12px;
padding: 4px 4px 4px var(--offset-left, 4px); padding: 4px 4px 4px var(--offset-left, 4px);
background-color: var(--titlebar-background-color, #030303); background-color: var(--titlebar-background-color, #030303);
user-select: none; user-select: none;
transition: opacity 200ms ease 0s, transition:
transform 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s, opacity 200ms ease 0s,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s; transform 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
&[data-macos="true"] { &[data-macos='true'] {
padding: 4px 4px 4px 74px; padding: 4px 4px 4px 74px;
} }
ytmusic-app:has(ytmusic-player[player-ui-state=FULLSCREEN]) ~ &:not([data-show="true"]) { ytmusic-app:has(ytmusic-player[player-ui-state='FULLSCREEN'])
transform: translateY(calc(-1 * var(--menu-bar-height, 32px))); ~ &:not([data-show='true']) {
} transform: translateY(calc(-1 * var(--menu-bar-height, 32px)));
`); }
`,
);
const separatorStyle = cacheNoArgs(() => css` const separatorStyle = cacheNoArgs(
min-height: 1px; () => css`
height: 1px; min-height: 1px;
margin: 4px 0; height: 1px;
margin: 4px 0;
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
`); `,
);
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -64,14 +80,18 @@ const animationStyle = cacheNoArgs(() => ({
transform: translateX(-50%) scale(0.8); transform: translateX(-50%) scale(0.8);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: translateX(-50%) scale(0.8); transform: translateX(-50%) scale(0.8);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.1s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
move: css` move: css`
transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1); transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
@ -89,7 +109,7 @@ export type PanelRendererProps = {
items: Electron.Menu['items']; items: Electron.Menu['items'];
level?: number[]; level?: number[];
onClick?: (commandId: number, radioGroup?: MenuItem[]) => void; onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
} };
const PanelRenderer = (props: PanelRendererProps) => { const PanelRenderer = (props: PanelRendererProps) => {
const radioGroup = () => props.items.filter((it) => it.type === 'radio'); const radioGroup = () => props.items.filter((it) => it.type === 'radio');
@ -114,12 +134,12 @@ const PanelRenderer = (props: PanelRendererProps) => {
name={subItem().label} name={subItem().label}
chip={subItem().sublabel} chip={subItem().sublabel}
toolTip={subItem().toolTip} toolTip={subItem().toolTip}
level={[...props.level ?? [], subItem().commandId]} level={[...(props.level ?? []), subItem().commandId]}
commandId={subItem().commandId} commandId={subItem().commandId}
> >
<PanelRenderer <PanelRenderer
items={subItem().submenu?.items ?? []} items={subItem().submenu?.items ?? []}
level={[...props.level ?? [], subItem().commandId]} level={[...(props.level ?? []), subItem().commandId]}
onClick={props.onClick} onClick={props.onClick}
/> />
</PanelItem> </PanelItem>
@ -143,11 +163,13 @@ const PanelRenderer = (props: PanelRendererProps) => {
chip={subItem().sublabel} chip={subItem().sublabel}
toolTip={subItem().toolTip} toolTip={subItem().toolTip}
commandId={subItem().commandId} commandId={subItem().commandId}
onChange={() => props.onClick?.(subItem().commandId, radioGroup())} onChange={() =>
props.onClick?.(subItem().commandId, radioGroup())
}
/> />
</Match> </Match>
<Match when={subItem().type === 'separator'}> <Match when={subItem().type === 'separator'}>
<hr class={separatorStyle()}/> <hr class={separatorStyle()} />
</Match> </Match>
</Switch> </Switch>
</Show> </Show>
@ -169,8 +191,13 @@ export const TitleBar = (props: TitleBarProps) => {
const [menu, setMenu] = createSignal<Menu | null>(null); const [menu, setMenu] = createSignal<Menu | null>(null);
const [mouseY, setMouseY] = createSignal(0); const [mouseY, setMouseY] = createSignal(0);
const [data, { refetch }] = createResource(async () => await props.ipc.invoke('get-menu') as Promise<Menu | null>); const [data, { refetch }] = createResource(
const [isMaximized, { refetch: refetchMaximize }] = createResource(async () => await props.ipc.invoke('window-is-maximized') as Promise<boolean>); async () => (await props.ipc.invoke('get-menu')) as Promise<Menu | null>,
);
const [isMaximized, { refetch: refetchMaximize }] = createResource(
async () =>
(await props.ipc.invoke('window-is-maximized')) as Promise<boolean>,
);
const handleToggleMaximize = async () => { const handleToggleMaximize = async () => {
if (isMaximized()) { if (isMaximized()) {
@ -194,10 +221,12 @@ export const TitleBar = (props: TitleBarProps) => {
)) as MenuItem | null; )) as MenuItem | null;
const newMenu = structuredClone(originalMenu); const newMenu = structuredClone(originalMenu);
const stack = [...newMenu?.items ?? []]; const stack = [...(newMenu?.items ?? [])];
let now: MenuItem | undefined = stack.pop(); let now: MenuItem | undefined = stack.pop();
while (now) { while (now) {
const index = now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ?? -1; const index =
now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ??
-1;
if (index >= 0) { if (index >= 0) {
if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem); if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem);
@ -213,13 +242,16 @@ export const TitleBar = (props: TitleBarProps) => {
return newMenu; return newMenu;
}; };
const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => { const handleItemClick = async (
commandId: number,
radioGroup?: MenuItem[],
) => {
const menuData = menu(); const menuData = menu();
if (!menuData) return; if (!menuData) return;
if (Array.isArray(radioGroup)) { if (Array.isArray(radioGroup)) {
let newMenu = menuData; let newMenu = menuData;
for await (const item of radioGroup) { for (const item of radioGroup) {
newMenu = await refreshMenuItem(newMenu, item.commandId); newMenu = await refreshMenuItem(newMenu, item.commandId);
} }
@ -272,18 +304,15 @@ export const TitleBar = (props: TitleBarProps) => {
window.addEventListener('mousemove', listener); window.addEventListener('mousemove', listener);
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout'); const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
ytmusicAppLayout?.addEventListener('scroll', () => { ytmusicAppLayout?.addEventListener('scroll', () => {
const scrollValue = ytmusicAppLayout.scrollTop; const scrollValue = ytmusicAppLayout.scrollTop;
if (scrollValue > 20){ if (scrollValue > 20) {
ytmusicAppLayout.classList.add('content-scrolled'); ytmusicAppLayout.classList.add('content-scrolled');
} } else {
else{ ytmusicAppLayout.classList.remove('content-scrolled');
ytmusicAppLayout.classList.remove('content-scrolled'); }
}
}); });
}); });
createEffect(() => { createEffect(() => {
if (!menu() && data()) { if (!menu() && data()) {
setMenu(data() ?? null); setMenu(data() ?? null);
@ -295,7 +324,12 @@ export const TitleBar = (props: TitleBarProps) => {
}); });
return ( return (
<nav data-ytmd-main-panel={true} class={titleStyle()} data-macos={props.isMacOS} data-show={mouseY() < 32}> <nav
data-ytmd-main-panel={true}
class={titleStyle()}
data-macos={props.isMacOS}
data-show={mouseY() < 32}
>
<IconButton <IconButton
onClick={() => setCollapsed(!collapsed())} onClick={() => setCollapsed(!collapsed())}
style={{ style={{
@ -310,15 +344,34 @@ export const TitleBar = (props: TitleBarProps) => {
</svg> </svg>
</IconButton> </IconButton>
<TransitionGroup <TransitionGroup
enterClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().enter} enterClass={
enterActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().enterActive} ignoreTransition()
exitToClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().exitTo} ? animationStyle().fakeTarget
exitActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().exitActive} : animationStyle().enter
}
enterActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().enterActive
}
exitToClass={
ignoreTransition()
? animationStyle().fakeTarget
: animationStyle().exitTo
}
exitActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().exitActive
}
onBeforeEnter={(element) => { onBeforeEnter={(element) => {
if (ignoreTransition()) return; if (ignoreTransition()) return;
const index = Number(element.getAttribute('data-index') ?? 0); const index = Number(element.getAttribute('data-index') ?? 0);
(element as HTMLElement).style.setProperty('transition-delay', `${(index * 0.025)}s`); (element as HTMLElement).style.setProperty(
'transition-delay',
`${index * 0.025}s`,
);
}} }}
onAfterEnter={(element) => { onAfterEnter={(element) => {
(element as HTMLElement).style.removeProperty('transition-delay'); (element as HTMLElement).style.removeProperty('transition-delay');
@ -328,13 +381,18 @@ export const TitleBar = (props: TitleBarProps) => {
const index = Number(element.getAttribute('data-index') ?? 0); const index = Number(element.getAttribute('data-index') ?? 0);
const length = Number(element.getAttribute('data-length') ?? 1); const length = Number(element.getAttribute('data-length') ?? 1);
(element as HTMLElement).style.setProperty('transition-delay', `${(length * 0.025) - (index * 0.025)}s`); (element as HTMLElement).style.setProperty(
'transition-delay',
`${length * 0.025 - index * 0.025}s`,
);
}} }}
> >
<Show when={!collapsed()}> <Show when={!collapsed()}>
<Index each={menu()?.items}> <Index each={menu()?.items}>
{(item, index) => { {(item, index) => {
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null); const [anchor, setAnchor] = createSignal<HTMLElement | null>(
null,
);
const handleClick = () => { const handleClick = () => {
if (openTarget() === anchor()) { if (openTarget() === anchor()) {
@ -372,7 +430,7 @@ export const TitleBar = (props: TitleBarProps) => {
</Show> </Show>
</TransitionGroup> </TransitionGroup>
<Show when={props.enableController}> <Show when={props.enableController}>
<div style={{ flex: 1 }}/> <div style={{ flex: 1 }} />
<WindowController <WindowController
isMaximize={isMaximized()} isMaximize={isMaximized()}
onToggleMaximize={handleToggleMaximize} onToggleMaximize={handleToggleMaximize}
@ -383,4 +441,3 @@ export const TitleBar = (props: TitleBarProps) => {
</nav> </nav>
); );
}; };

View File

@ -4,19 +4,21 @@ import { Show } from 'solid-js';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const containerStyle = cacheNoArgs(() => css` const containerStyle = cacheNoArgs(
display: flex; () => css`
justify-content: flex-end; display: flex;
align-items: center; justify-content: flex-end;
align-items: center;
& > *:last-of-type { & > *:last-of-type {
border-top-right-radius: 4px; border-top-right-radius: 4px;
&:hover { &:hover {
background: rgba(255, 0, 0, 0.5); background: rgba(255, 0, 0, 0.5);
}
} }
} `,
`); );
export type WindowControllerProps = { export type WindowControllerProps = {
isMaximize?: boolean; isMaximize?: boolean;
@ -24,20 +26,35 @@ export type WindowControllerProps = {
onToggleMaximize?: () => void; onToggleMaximize?: () => void;
onMinimize?: () => void; onMinimize?: () => void;
onClose?: () => void; onClose?: () => void;
} };
export const WindowController = (props: WindowControllerProps) => { export const WindowController = (props: WindowControllerProps) => {
return ( return (
<div class={containerStyle()}> <div class={containerStyle()}>
<IconButton onClick={props.onMinimize}> <IconButton onClick={props.onMinimize}>
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path fill="currentColor" d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z"/> width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z"
/>
</svg> </svg>
</IconButton> </IconButton>
<IconButton onClick={props.onToggleMaximize}> <IconButton onClick={props.onToggleMaximize}>
<Show <Show
when={props.isMaximize} when={props.isMaximize}
fallback={ fallback={
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z"
@ -45,7 +62,13 @@ export const WindowController = (props: WindowControllerProps) => {
</svg> </svg>
} }
> >
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z" d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z"
@ -54,7 +77,13 @@ export const WindowController = (props: WindowControllerProps) => {
</Show> </Show>
</IconButton> </IconButton>
<IconButton onClick={props.onClose}> <IconButton onClick={props.onClose}>
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z" d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z"

View File

@ -77,7 +77,7 @@ export const onRendererLoad = ({
applyLyricsTabState(); applyLyricsTabState();
} }
}; };
const applyLyricsTabState = () => { const applyLyricsTabState = () => {
if (lyrics) { if (lyrics) {
tabs.lyrics.removeAttribute('disabled'); tabs.lyrics.removeAttribute('disabled');

View File

@ -3,13 +3,15 @@ import { DataConnection, Peer } from 'peerjs';
import type { Permission, Profile, VideoData } from './types'; import type { Permission, Profile, VideoData } from './types';
export type ConnectionEventMap = { export type ConnectionEventMap = {
ADD_SONGS: { videoList: VideoData[], index?: number }; ADD_SONGS: { videoList: VideoData[]; index?: number };
REMOVE_SONG: { index: number }; REMOVE_SONG: { index: number };
MOVE_SONG: { fromIndex: number; toIndex: number }; MOVE_SONG: { fromIndex: number; toIndex: number };
IDENTIFY: { profile: Profile } | undefined; IDENTIFY: { profile: Profile } | undefined;
SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined; SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined;
SYNC_QUEUE: { videoList: VideoData[] } | undefined; SYNC_QUEUE: { videoList: VideoData[] } | undefined;
SYNC_PROGRESS: { progress?: number; state?: number; index?: number; } | undefined; SYNC_PROGRESS:
| { progress?: number; state?: number; index?: number }
| undefined;
PERMISSION: Permission | undefined; PERMISSION: Permission | undefined;
}; };
export type ConnectionEventUnion = { export type ConnectionEventUnion = {
@ -24,9 +26,12 @@ type PromiseUtil<T> = {
promise: Promise<T>; promise: Promise<T>;
resolve: (id: T) => void; resolve: (id: T) => void;
reject: (err: unknown) => void; reject: (err: unknown) => void;
} };
export type ConnectionListener = (event: ConnectionEventUnion, conn: DataConnection) => void; export type ConnectionListener = (
event: ConnectionEventUnion,
conn: DataConnection,
) => void;
export type ConnectionMode = 'host' | 'guest' | 'disconnected'; export type ConnectionMode = 'host' | 'guest' | 'disconnected';
export class Connection { export class Connection {
private peer: Peer; private peer: Peer;
@ -95,9 +100,12 @@ export class Connection {
return Object.values(this.connections); return Object.values(this.connections);
} }
public async broadcast<Event extends keyof ConnectionEventMap>(type: Event, payload: ConnectionEventMap[Event]) { public async broadcast<Event extends keyof ConnectionEventMap>(
type: Event,
payload: ConnectionEventMap[Event],
) {
await Promise.all( await Promise.all(
this.getConnections().map((conn) => conn.send({ type, payload })) this.getConnections().map((conn) => conn.send({ type, payload })),
); );
} }
@ -125,7 +133,13 @@ export class Connection {
this.connectionListeners.forEach((listener) => listener(conn)); this.connectionListeners.forEach((listener) => listener(conn));
conn.on('data', (data) => { conn.on('data', (data) => {
if (!data || typeof data !== 'object' || !('type' in data) || !('payload' in data) || !data.type) { if (
!data ||
typeof data !== 'object' ||
!('type' in data) ||
!('payload' in data) ||
!data.type
) {
console.warn('Music Together: Invalid data', data); console.warn('Music Together: Invalid data', data);
return; return;
} }

View File

@ -4,7 +4,7 @@ import itemHTML from './templates/item.html?raw';
import popupHTML from './templates/popup.html?raw'; import popupHTML from './templates/popup.html?raw';
type Placement = type Placement =
'top' | 'top'
| 'bottom' | 'bottom'
| 'right' | 'right'
| 'left' | 'left'
@ -15,32 +15,40 @@ type Placement =
| 'top-right' | 'top-right'
| 'bottom-left' | 'bottom-left'
| 'bottom-right'; | 'bottom-right';
type PopupItem = (ItemRendererProps & { type: 'item'; }) type PopupItem =
| { type: 'divider'; } | (ItemRendererProps & { type: 'item' })
| { type: 'custom'; element: HTMLElement; }; | { type: 'divider' }
| { type: 'custom'; element: HTMLElement };
type PopupProps = { type PopupProps = {
data: PopupItem[]; data: PopupItem[];
anchorAt?: Placement; anchorAt?: Placement;
popupAt?: Placement; popupAt?: Placement;
} };
export const Popup = (props: PopupProps) => { export const Popup = (props: PopupProps) => {
const popup = ElementFromHtml(popupHTML); const popup = ElementFromHtml(popupHTML);
const container = popup.querySelector<HTMLElement>('.music-together-popup-container')!; const container = popup.querySelector<HTMLElement>(
'.music-together-popup-container',
)!;
const items = props.data const items = props.data
.map((props) => { .map((props) => {
if (props.type === 'item') return { if (props.type === 'item')
type: 'item' as const, return {
...ItemRenderer(props), type: 'item' as const,
}; ...ItemRenderer(props),
if (props.type === 'divider') return { };
type: 'divider' as const, if (props.type === 'divider')
element: ElementFromHtml('<div class="music-together-divider horizontal"></div>'), return {
}; type: 'divider' as const,
if (props.type === 'custom') return { element: ElementFromHtml(
type: 'custom' as const, '<div class="music-together-divider horizontal"></div>',
element: props.element, ),
}; };
if (props.type === 'custom')
return {
type: 'custom' as const,
element: props.element,
};
return null; return null;
}) })
@ -80,7 +88,9 @@ export const Popup = (props: PopupProps) => {
setTimeout(() => { setTimeout(() => {
const onClose = (event: MouseEvent) => { const onClose = (event: MouseEvent) => {
const isPopupClick = event.composedPath().some((element) => element === popup); const isPopupClick = event
.composedPath()
.some((element) => element === popup);
if (!isPopupClick) { if (!isPopupClick) {
this.dismiss(); this.dismiss();
document.removeEventListener('click', onClose); document.removeEventListener('click', onClose);
@ -101,7 +111,7 @@ export const Popup = (props: PopupProps) => {
dismiss() { dismiss() {
popup.style.setProperty('opacity', '0'); popup.style.setProperty('opacity', '0');
popup.style.setProperty('pointer-events', 'none'); popup.style.setProperty('pointer-events', 'none');
} },
}; };
}; };
@ -133,6 +143,6 @@ export const ItemRenderer = (props: ItemRendererProps) => {
setText(text: string) { setText(text: string) {
textContainer.replaceChildren(text); textContainer.replaceChildren(text);
}, },
id: props.id id: props.id,
}; };
}; };

View File

@ -6,7 +6,12 @@ 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 { getDefaultProfile, type Permission, type Profile, type VideoData } from './types'; import {
getDefaultProfile,
type Permission,
type Profile,
type VideoData,
} from './types';
import { Queue } from './queue'; import { Queue } from './queue';
import { Connection, type ConnectionEventUnion } from './connection'; import { Connection, type ConnectionEventUnion } from './connection';
import { createHostPopup } from './ui/host'; import { createHostPopup } from './ui/host';
@ -26,7 +31,7 @@ type RawAccountData = {
runs: { text: string }[]; runs: { text: string }[];
}; };
accountPhoto: { accountPhoto: {
thumbnails: { url: string; width: number; height: number; }[]; thumbnails: { url: string; width: number; height: number }[];
}; };
settingsEndpoint: unknown; settingsEndpoint: unknown;
manageAccountTitle: unknown; manageAccountTitle: unknown;
@ -59,7 +64,7 @@ export default createPlugin<
stateInterval?: number; stateInterval?: number;
updateNext: boolean; updateNext: boolean;
ignoreChange: boolean; ignoreChange: boolean;
rollbackInjector?: (() => void); rollbackInjector?: () => void;
me?: Omit<Profile, 'id'>; me?: Omit<Profile, 'id'>;
profiles: Record<string, Profile>; profiles: Record<string, Profile>;
permission: Permission; permission: Permission;
@ -79,16 +84,18 @@ export default createPlugin<
restartNeeded: false, restartNeeded: false,
addedVersion: '3.2.X', addedVersion: '3.2.X',
config: { config: {
enabled: false enabled: false,
}, },
stylesheets: [style], stylesheets: [style],
backend({ ipc }) { backend({ ipc }) {
ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ ipc.handle('music-together:prompt', async (title: string, label: string) =>
title, prompt({
label, title,
type: 'input', label,
...promptOptions() type: 'input',
})); ...promptOptions(),
}),
);
}, },
renderer: { renderer: {
updateNext: false, updateNext: false,
@ -112,15 +119,19 @@ export default createPlugin<
videoChangeListener(event: CustomEvent<VideoDataChanged>) { videoChangeListener(event: CustomEvent<VideoDataChanged>) {
if (event.detail.name === 'dataloaded' || this.updateNext) { if (event.detail.name === 'dataloaded' || this.updateNext) {
if (this.connection?.mode === 'host') { if (this.connection?.mode === 'host') {
const videoList: VideoData[] = this.queue?.flatItems.map((it) => ({ const videoList: VideoData[] =
videoId: it!.videoId, this.queue?.flatItems.map(
ownerId: this.connection!.id (it) =>
} satisfies VideoData)) ?? []; ({
videoId: it!.videoId,
ownerId: this.connection!.id,
}) satisfies VideoData,
) ?? [];
this.queue?.setVideoList(videoList, false); this.queue?.setVideoList(videoList, false);
this.queue?.syncQueueOwner(); this.queue?.syncQueueOwner();
this.connection.broadcast('SYNC_QUEUE', { this.connection.broadcast('SYNC_QUEUE', {
videoList videoList,
}); });
this.updateNext = event.detail.name === 'dataloaded'; this.updateNext = event.detail.name === 'dataloaded';
@ -138,7 +149,7 @@ export default createPlugin<
this.connection.broadcast('SYNC_PROGRESS', { this.connection.broadcast('SYNC_PROGRESS', {
// progress: this.playerApi?.getCurrentTime(), // progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState() state: this.playerApi?.getPlayerState(),
// index: this.queue?.selectedIndex ?? 0, // index: this.queue?.selectedIndex ?? 0,
}); });
}, },
@ -150,13 +161,17 @@ export default createPlugin<
if (!wait) return false; if (!wait) return false;
if (!this.me) this.me = getDefaultProfile(this.connection.id); if (!this.me) this.me = getDefaultProfile(this.connection.id);
const rawItems = this.queue?.flatItems?.map((it) => ({ const rawItems =
videoId: it!.videoId, this.queue?.flatItems?.map(
ownerId: this.connection!.id (it) =>
} satisfies VideoData)) ?? []; ({
videoId: it!.videoId,
ownerId: this.connection!.id,
}) satisfies VideoData,
) ?? [];
this.queue?.setOwner({ this.queue?.setOwner({
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
this.queue?.setVideoList(rawItems, false); this.queue?.setVideoList(rawItems, false);
this.queue?.syncQueueOwner(); this.queue?.syncQueueOwner();
@ -166,31 +181,41 @@ export default createPlugin<
this.profiles = {}; this.profiles = {};
this.connection.onConnections((connection) => { this.connection.onConnections((connection) => {
if (!connection) { if (!connection) {
this.api?.openToast(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop(); this.onStop();
return; return;
} }
if (!connection.open) { if (!connection.open) {
this.api?.openToast(t('plugins.music-together.toast.user-disconnected', { this.api?.toastService?.show(
name: this.profiles[connection.peer]?.name t('plugins.music-together.toast.user-disconnected', {
})); name: this.profiles[connection.peer]?.name,
}),
);
this.putProfile(connection.peer, undefined); this.putProfile(connection.peer, undefined);
} }
}); });
this.putProfile(this.connection.id, { this.putProfile(this.connection.id, {
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
const listener = async (event: ConnectionEventUnion, conn?: DataConnection) => { const listener = async (
event: ConnectionEventUnion,
conn?: DataConnection,
) => {
this.ignoreChange = true; this.ignoreChange = true;
switch (event.type) { switch (event.type) {
case 'ADD_SONGS': { case 'ADD_SONGS': {
if (conn && this.permission === 'host-only') return; if (conn && this.permission === 'host-only') return;
await this.queue?.addVideos(event.payload.videoList, event.payload.index); await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
await this.connection?.broadcast('ADD_SONGS', event.payload); await this.connection?.broadcast('ADD_SONGS', event.payload);
break; break;
} }
@ -204,27 +229,38 @@ export default createPlugin<
case 'MOVE_SONG': { case 'MOVE_SONG': {
if (conn && this.permission === 'host-only') { if (conn && this.permission === 'host-only') {
await this.connection?.broadcast('SYNC_QUEUE', { await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [] videoList: this.queue?.videoList ?? [],
}); });
break; break;
} }
this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
await this.connection?.broadcast('MOVE_SONG', event.payload); await this.connection?.broadcast('MOVE_SONG', event.payload);
break; break;
} }
case 'IDENTIFY': { case 'IDENTIFY': {
if (!event.payload || !conn) { if (!event.payload || !conn) {
console.warn('Music Together [Host]: Received "IDENTIFY" event without payload or connection'); console.warn(
'Music Together [Host]: Received "IDENTIFY" event without payload or connection',
);
break; break;
} }
this.api?.openToast(t('plugins.music-together.toast.user-connected', { name: event.payload.profile.name })); this.api?.toastService?.show(
t('plugins.music-together.toast.user-connected', {
name: event.payload.profile.name,
}),
);
this.putProfile(conn.peer, event.payload.profile); this.putProfile(conn.peer, event.payload.profile);
break; break;
} }
case 'SYNC_PROFILE': { case 'SYNC_PROFILE': {
await this.connection?.broadcast('SYNC_PROFILE', { profiles: this.profiles }); await this.connection?.broadcast('SYNC_PROFILE', {
profiles: this.profiles,
});
break; break;
} }
@ -237,7 +273,7 @@ export default createPlugin<
} }
case 'SYNC_QUEUE': { case 'SYNC_QUEUE': {
await this.connection?.broadcast('SYNC_QUEUE', { await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [] videoList: this.queue?.videoList ?? [],
}); });
break; break;
} }
@ -251,7 +287,8 @@ export default createPlugin<
if (permissionLevel >= 2) { if (permissionLevel >= 2) {
if (typeof event.payload?.progress === 'number') { if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
} }
if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 2) this.playerApi?.pauseVideo();
@ -300,25 +337,32 @@ export default createPlugin<
this.profiles = {}; this.profiles = {};
const id = await this.showPrompt(t('plugins.music-together.name'), t('plugins.music-together.dialog.enter-host')); const id = await this.showPrompt(
t('plugins.music-together.name'),
t('plugins.music-together.dialog.enter-host'),
);
if (typeof id !== 'string') return false; if (typeof id !== 'string') return false;
const connection = await this.connection.connect(id).catch(() => false); const connection = await this.connection.connect(id).catch(() => false);
if (!connection) return false; if (!connection) return false;
this.connection.onConnections((connection) => { this.connection.onConnections((connection) => {
if (!connection?.open) { if (!connection?.open) {
this.api?.openToast(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop(); this.onStop();
} }
}); });
let resolveIgnore: number | null = null; let resolveIgnore: number | null = null;
const listener = async (event: ConnectionEventUnion) => { const listener = async (event: ConnectionEventUnion) => {
this.ignoreChange = true; this.ignoreChange = true;
switch (event.type) { switch (event.type) {
case 'ADD_SONGS': { case 'ADD_SONGS': {
await this.queue?.addVideos(event.payload.videoList, event.payload.index); await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
break; break;
} }
case 'REMOVE_SONG': { case 'REMOVE_SONG': {
@ -326,11 +370,16 @@ export default createPlugin<
break; break;
} }
case 'MOVE_SONG': { case 'MOVE_SONG': {
this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
break; break;
} }
case 'IDENTIFY': { case 'IDENTIFY': {
console.warn('Music Together [Guest]: Received "IDENTIFY" event from guest'); console.warn(
'Music Together [Guest]: Received "IDENTIFY" event from guest',
);
break; break;
} }
case 'SYNC_QUEUE': { case 'SYNC_QUEUE': {
@ -341,7 +390,9 @@ export default createPlugin<
} }
case 'SYNC_PROFILE': { case 'SYNC_PROFILE': {
if (!event.payload) { if (!event.payload) {
console.warn('Music Together [Guest]: Received "SYNC_PROFILE" event without payload'); console.warn(
'Music Together [Guest]: Received "SYNC_PROFILE" event without payload',
);
break; break;
} }
@ -353,7 +404,8 @@ export default createPlugin<
case 'SYNC_PROGRESS': { case 'SYNC_PROGRESS': {
if (typeof event.payload?.progress === 'number') { if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
} }
if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 2) this.playerApi?.pauseVideo();
@ -370,7 +422,9 @@ export default createPlugin<
} }
case 'PERMISSION': { case 'PERMISSION': {
if (!event.payload) { if (!event.payload) {
console.warn('Music Together [Guest]: Received "PERMISSION" event without payload'); console.warn(
'Music Together [Guest]: Received "PERMISSION" event without payload',
);
break; break;
} }
@ -379,9 +433,15 @@ export default createPlugin<
this.popups.host.setPermission(this.permission); this.popups.host.setPermission(this.permission);
this.popups.setting.setPermission(this.permission); this.popups.setting.setPermission(this.permission);
const permissionLabel = t(`plugins.music-together.menu.permission.${this.permission}`); const permissionLabel = t(
`plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.openToast(t('plugins.music-together.toast.permission-changed', { permission: permissionLabel })); this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
break; break;
} }
default: { default: {
@ -415,8 +475,10 @@ export default createPlugin<
break; break;
} }
case 'SYNC_PROGRESS': { case 'SYNC_PROGRESS': {
if (this.permission === 'host-only') await this.connection?.broadcast('SYNC_QUEUE', undefined); if (this.permission === 'host-only')
else await this.connection?.broadcast('SYNC_PROGRESS', event.payload); await this.connection?.broadcast('SYNC_QUEUE', undefined);
else
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
break; break;
} }
} }
@ -431,12 +493,16 @@ export default createPlugin<
this.queue?.injection(); this.queue?.injection();
this.queue?.setOwner({ this.queue?.setOwner({
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
const progress = Array.from(document.querySelectorAll<HTMLElement & { const progress = Array.from(
_update: (...args: unknown[]) => void document.querySelectorAll<
}>('tp-yt-paper-progress')); HTMLElement & {
_update: (...args: unknown[]) => void;
}
>('tp-yt-paper-progress'),
);
const rollbackList = progress.map((progress) => { const rollbackList = progress.map((progress) => {
const original = progress._update; const original = progress._update;
progress._update = (...args) => { progress._update = (...args) => {
@ -444,10 +510,11 @@ export default createPlugin<
if (this.permission === 'all' && typeof now === 'number') { if (this.permission === 'all' && typeof now === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(now - currentTime) > 3) this.connection?.broadcast('SYNC_PROGRESS', { if (Math.abs(now - currentTime) > 3)
progress: now, this.connection?.broadcast('SYNC_PROGRESS', {
state: this.playerApi?.getPlayerState() progress: now,
}); state: this.playerApi?.getPlayerState(),
});
} }
original.call(progress, ...args); original.call(progress, ...args);
@ -466,8 +533,8 @@ export default createPlugin<
id: this.connection.id, id: this.connection.id,
handleId: this.me.handleId, handleId: this.me.handleId,
name: this.me.name, name: this.me.name,
thumbnail: this.me.thumbnail thumbnail: this.me.thumbnail,
} },
}); });
this.connection.broadcast('SYNC_PROFILE', undefined); this.connection.broadcast('SYNC_PROFILE', undefined);
@ -525,14 +592,18 @@ export default createPlugin<
}, },
initMyProfile() { initMyProfile() {
const accountButton = document.querySelector<HTMLElement & { const accountButton = document.querySelector<
onButtonTap: () => void HTMLElement & {
}>('ytmusic-settings-button'); onButtonTap: () => void;
}
>('ytmusic-settings-button');
accountButton?.onButtonTap(); accountButton?.onButtonTap();
setTimeout(() => { setTimeout(() => {
accountButton?.onButtonTap(); accountButton?.onButtonTap();
const renderer = document.querySelector<HTMLElement & { data: unknown }>('ytd-active-account-header-renderer'); const renderer = document.querySelector<
HTMLElement & { data: unknown }
>('ytd-active-account-header-renderer');
if (!accountButton || !renderer) { if (!accountButton || !renderer) {
console.warn('Music Together: Cannot find account'); console.warn('Music Together: Cannot find account');
this.me = getDefaultProfile(this.connection?.id ?? ''); this.me = getDefaultProfile(this.connection?.id ?? '');
@ -543,7 +614,7 @@ export default createPlugin<
this.me = { this.me = {
handleId: accountData.channelHandle.runs[0].text, handleId: accountData.channelHandle.runs[0].text,
name: accountData.accountName.runs[0].text, name: accountData.accountName.runs[0].text,
thumbnail: accountData.accountPhoto.thumbnails[0].url thumbnail: accountData.accountPhoto.thumbnails[0].url,
}; };
if (this.me.thumbnail) { if (this.me.thumbnail) {
@ -557,14 +628,23 @@ 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<AppElement>('ytmusic-app'); this.api = document.querySelector<AppElement>('ytmusic-app');
/* setup */ /* setup */
document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML); document
const setting = document.querySelector<HTMLElement>('#music-together-setting-button'); .querySelector('#right-content > ytmusic-settings-button')
const icon = document.querySelector<SVGElement>('#music-together-setting-button > svg'); ?.insertAdjacentHTML('beforebegin', settingHTML);
const spinner = document.querySelector<HTMLElement>('#music-together-setting-button > tp-yt-paper-spinner-lite'); const setting = document.querySelector<HTMLElement>(
'#music-together-setting-button',
);
const icon = document.querySelector<SVGElement>(
'#music-together-setting-button > svg',
);
const spinner = document.querySelector<HTMLElement>(
'#music-together-setting-button > tp-yt-paper-spinner-lite',
);
if (!setting || !icon || !spinner) { if (!setting || !icon || !spinner) {
console.warn('Music Together: Cannot inject html'); console.warn('Music Together: Cannot inject html');
console.log(setting, icon, spinner); console.log(setting, icon, spinner);
@ -574,7 +654,7 @@ export default createPlugin<
this.elements = { this.elements = {
setting, setting,
icon, icon,
spinner spinner,
}; };
this.stateInterval = window.setInterval(() => { this.stateInterval = window.setInterval(() => {
@ -584,7 +664,7 @@ export default createPlugin<
this.connection.broadcast('SYNC_PROGRESS', { this.connection.broadcast('SYNC_PROGRESS', {
progress: this.playerApi?.getCurrentTime(), progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState(), state: this.playerApi?.getPlayerState(),
index index,
}); });
}, 1000); }, 1000);
@ -593,18 +673,25 @@ export default createPlugin<
onItemClick: (id) => { onItemClick: (id) => {
if (id === 'music-together-close') { if (id === 'music-together-close') {
this.onStop(); this.onStop();
this.api?.openToast(t('plugins.music-together.toast.closed')); this.api?.toastService?.show(
t('plugins.music-together.toast.closed'),
);
hostPopup.dismiss(); hostPopup.dismiss();
} }
if (id === 'music-together-copy-id') { if (id === 'music-together-copy-id') {
navigator.clipboard.writeText(this.connection?.id ?? '') navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => { .then(() => {
this.api?.openToast(t('plugins.music-together.toast.id-copied')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.dismiss(); hostPopup.dismiss();
}) })
.catch(() => { .catch(() => {
this.api?.openToast(t('plugins.music-together.toast.id-copy-failed')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.dismiss(); hostPopup.dismiss();
}); });
} }
@ -612,30 +699,39 @@ export default createPlugin<
if (id === 'music-together-permission') { if (id === 'music-together-permission') {
if (this.permission === 'all') this.permission = 'host-only'; if (this.permission === 'all') this.permission = 'host-only';
else if (this.permission === 'playlist') this.permission = 'all'; else if (this.permission === 'playlist') this.permission = 'all';
else if (this.permission === 'host-only') this.permission = 'playlist'; else if (this.permission === 'host-only')
this.permission = 'playlist';
this.connection?.broadcast('PERMISSION', this.permission); this.connection?.broadcast('PERMISSION', this.permission);
hostPopup.setPermission(this.permission); hostPopup.setPermission(this.permission);
guestPopup.setPermission(this.permission); guestPopup.setPermission(this.permission);
settingPopup.setPermission(this.permission); settingPopup.setPermission(this.permission);
const permissionLabel = t(`plugins.music-together.menu.permission.${this.permission}`); const permissionLabel = t(
this.api?.openToast(t('plugins.music-together.toast.permission-changed', { permission: permissionLabel })); `plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
const item = hostPopup.items.find((it) => it?.element.id === id); const item = hostPopup.items.find((it) => it?.element.id === id);
if (item?.type === 'item') { if (item?.type === 'item') {
item.setText(t('plugins.music-together.menu.set-permission')); item.setText(t('plugins.music-together.menu.set-permission'));
} }
} }
} },
}); });
const guestPopup = createGuestPopup({ const guestPopup = createGuestPopup({
onItemClick: (id) => { onItemClick: (id) => {
if (id === 'music-together-disconnect') { if (id === 'music-together-disconnect') {
this.onStop(); this.onStop();
this.api?.openToast(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
guestPopup.dismiss(); guestPopup.dismiss();
} }
} },
}); });
const settingPopup = createSettingPopup({ const settingPopup = createSettingPopup({
onItemClick: async (id) => { onItemClick: async (id) => {
@ -646,16 +742,24 @@ export default createPlugin<
this.hideSpinner(); this.hideSpinner();
if (result) { if (result) {
navigator.clipboard.writeText(this.connection?.id ?? '') navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => { .then(() => {
this.api?.openToast(t('plugins.music-together.toast.id-copied')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.showAtAnchor(setting); hostPopup.showAtAnchor(setting);
}).catch(() => { })
this.api?.openToast(t('plugins.music-together.toast.id-copy-failed')); .catch(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.showAtAnchor(setting); hostPopup.showAtAnchor(setting);
}); });
} else { } else {
this.api?.openToast(t('plugins.music-together.toast.host-failed')); this.api?.toastService?.show(
t('plugins.music-together.toast.host-failed'),
);
} }
} }
@ -666,18 +770,22 @@ export default createPlugin<
this.hideSpinner(); this.hideSpinner();
if (result) { if (result) {
this.api?.openToast(t('plugins.music-together.toast.joined')); this.api?.toastService?.show(
t('plugins.music-together.toast.joined'),
);
guestPopup.showAtAnchor(setting); guestPopup.showAtAnchor(setting);
} else { } else {
this.api?.openToast(t('plugins.music-together.toast.join-failed')); this.api?.toastService?.show(
t('plugins.music-together.toast.join-failed'),
);
} }
} }
} },
}); });
this.popups = { this.popups = {
host: hostPopup, host: hostPopup,
guest: guestPopup, guest: guestPopup,
setting: settingPopup setting: settingPopup,
}; };
setting.addEventListener('click', () => { setting.addEventListener('click', () => {
let popup = settingPopup; let popup = settingPopup;
@ -695,24 +803,38 @@ export default createPlugin<
this.queue = new Queue({ this.queue = new Queue({
owner: { owner: {
id: this.connection?.id ?? '', id: this.connection?.id ?? '',
...this.me! ...this.me!,
}, },
getProfile: (id) => this.profiles[id] getProfile: (id) => this.profiles[id],
}); });
this.playerApi = playerApi; this.playerApi = playerApi;
this.playerApi.addEventListener('onStateChange', this.videoStateChangeListener); this.playerApi.addEventListener(
'onStateChange',
this.videoStateChangeListener,
);
document.addEventListener('videodatachange', this.videoChangeListener); document.addEventListener('videodatachange', this.videoChangeListener);
}, },
stop() { stop() {
const dividers = Array.from(document.querySelectorAll('.music-together-divider')); const dividers = Array.from(
document.querySelectorAll('.music-together-divider'),
);
dividers.forEach((divider) => divider.remove()); dividers.forEach((divider) => divider.remove());
this.elements.setting?.remove(); this.elements.setting?.remove();
this.onStop(); this.onStop();
if (typeof this.stateInterval === 'number') clearInterval(this.stateInterval); if (typeof this.stateInterval === 'number')
if (this.playerApi) this.playerApi.removeEventListener('onStateChange', this.videoStateChangeListener); clearInterval(this.stateInterval);
if (this.videoChangeListener) document.removeEventListener('videodatachange', this.videoChangeListener); if (this.playerApi)
} this.playerApi.removeEventListener(
} 'onStateChange',
this.videoStateChangeListener,
);
if (this.videoChangeListener)
document.removeEventListener(
'videodatachange',
this.videoChangeListener,
);
},
},
}); });

View File

@ -1,11 +1,20 @@
import { SHA1Hash } from './sha1hash'; import { SHA1Hash } from './sha1hash';
export const extractToken = (cookie = document.cookie) => cookie.match(/SAPISID=([^;]+);/)?.[1] ?? cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1]; export const extractToken = (cookie = document.cookie) =>
cookie.match(/SAPISID=([^;]+);/)?.[1] ??
cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1];
export const getHash = async (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => export const getHash = async (
(await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase(); papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => (await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase();
export const getAuthorizationHeader = async (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => { export const getAuthorizationHeader = async (
papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => {
return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`; return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`;
}; };
@ -23,15 +32,17 @@ export const getClient = () => {
platform: 'DESKTOP', platform: 'DESKTOP',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locationInfo: { locationInfo: {
locationPermissionAuthorizationStatus: 'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED', locationPermissionAuthorizationStatus:
'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
}, },
musicAppInfo: { musicAppInfo: {
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN', pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER', webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
storeDigitalGoodsApiSupportStatus: { storeDigitalGoodsApiSupportStatus: {
playStoreDigitalGoodsApiSupportStatus: 'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED', playStoreDigitalGoodsApiSupportStatus:
'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED',
}, },
}, },
utcOffsetMinutes: -1 * (new Date()).getTimezoneOffset(), utcOffsetMinutes: -1 * new Date().getTimezoneOffset(),
}; };
}; };

View File

@ -54,46 +54,46 @@ const getHeaderPayload = (() => {
title: { title: {
runs: [ runs: [
{ {
text: t('plugins.music-together.internal.track-source') text: t('plugins.music-together.internal.track-source'),
} },
] ],
}, },
subtitle: { subtitle: {
runs: [ runs: [
{ {
text: t('plugins.music-together.name') text: t('plugins.music-together.name'),
} },
] ],
}, },
buttons: [ buttons: [
{ {
chipCloudChipRenderer: { chipCloudChipRenderer: {
style: { style: {
styleType: 'STYLE_TRANSPARENT' styleType: 'STYLE_TRANSPARENT',
}, },
text: { text: {
runs: [ runs: [
{ {
text: t('plugins.music-together.internal.save') text: t('plugins.music-together.internal.save'),
} },
] ],
}, },
navigationEndpoint: { navigationEndpoint: {
saveQueueToPlaylistCommand: {} saveQueueToPlaylistCommand: {},
}, },
icon: { icon: {
iconType: 'ADD_TO_PLAYLIST' iconType: 'ADD_TO_PLAYLIST',
}, },
accessibilityData: { accessibilityData: {
accessibilityData: { accessibilityData: {
label: t('plugins.music-together.internal.save') label: t('plugins.music-together.internal.save'),
} },
}, },
isSelected: false, isSelected: false,
uniqueId: t('plugins.music-together.internal.save') uniqueId: t('plugins.music-together.internal.save'),
} },
} },
] ],
}; };
} }
@ -106,7 +106,7 @@ export type QueueOptions = {
owner?: Profile; owner?: Profile;
queue?: QueueElement; 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 {
@ -114,7 +114,7 @@ export class Queue {
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;
@ -126,7 +126,8 @@ export class Queue {
constructor(options: QueueOptions) { constructor(options: QueueOptions) {
this.getProfile = options.getProfile; this.getProfile = options.getProfile;
this.queue = options.queue ?? (document.querySelector<QueueElement>('#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 ?? [];
} }
@ -139,7 +140,12 @@ export class Queue {
} }
get selectedIndex() { get selectedIndex() {
return mapQueueItem((it) => it?.selected, this.queue.queue.store.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() {
@ -162,7 +168,9 @@ export class Queue {
} }
async addVideos(videos: VideoData[], index?: number) { async addVideos(videos: VideoData[], index?: number) {
const response = await getMusicQueueRenderer(videos.map((it) => it.videoId)); const response = await getMusicQueueRenderer(
videos.map((it) => it.videoId),
);
if (!response) return false; if (!response) return false;
const items = response.queueDatas.map((it) => it?.content).filter(Boolean); const items = response.queueDatas.map((it) => it?.content).filter(Boolean);
@ -173,12 +181,16 @@ export class Queue {
this.queue?.dispatch({ this.queue?.dispatch({
type: 'ADD_ITEMS', type: 'ADD_ITEMS',
payload: { payload: {
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId, nextQueueItemId:
index: index ?? this.queue.queue.store.store.getState().queue.items.length ?? 0, this.queue.queue.store.store.getState().queue.nextQueueItemId,
index:
index ??
this.queue.queue.store.store.getState().queue.items.length ??
0,
items, items,
shuffleEnabled: false, shuffleEnabled: false,
shouldAssignIds: true shouldAssignIds: true,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -194,7 +206,7 @@ export class Queue {
this._videoList.splice(index, 1); this._videoList.splice(index, 1);
this.queue?.dispatch({ this.queue?.dispatch({
type: 'REMOVE_ITEM', type: 'REMOVE_ITEM',
payload: index payload: index,
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -207,7 +219,7 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this.queue?.dispatch({ this.queue?.dispatch({
type: 'SET_INDEX', type: 'SET_INDEX',
payload: index payload: index,
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
@ -220,8 +232,8 @@ export class Queue {
type: 'MOVE_ITEM', type: 'MOVE_ITEM',
payload: { payload: {
fromIndex, fromIndex,
toIndex toIndex,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -234,7 +246,7 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this._videoList = []; this._videoList = [];
this.queue?.dispatch({ this.queue?.dispatch({
type: 'CLEAR' type: 'CLEAR',
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
@ -253,7 +265,8 @@ export class Queue {
return; return;
} }
if (this.originalDispatch) this.queue.queue.store.store.dispatch = this.originalDispatch; if (this.originalDispatch)
this.queue.queue.store.store.dispatch = this.originalDispatch;
} }
injection() { injection() {
@ -276,40 +289,54 @@ export class Queue {
if (event.type === 'ADD_ITEMS') { if (event.type === 'ADD_ITEMS') {
if (this.ignoreFlag) { if (this.ignoreFlag) {
this.ignoreFlag = false; this.ignoreFlag = false;
const videoList = mapQueueItem((it) => ({ const videoList = mapQueueItem(
videoId: it!.videoId, (it) =>
ownerId: this.owner!.id ({
} satisfies VideoData), event.payload!.items!); videoId: it!.videoId,
ownerId: this.owner!.id,
}) satisfies VideoData,
event.payload!.items!,
);
const index = this._videoList.length + videoList.length - 1; const index = this._videoList.length + videoList.length - 1;
if (videoList.length > 0) { if (videoList.length > 0) {
this.broadcast({ // play this.broadcast({
// play
type: 'ADD_SONGS', type: 'ADD_SONGS',
payload: { payload: {
videoList videoList,
}, },
after: [ after: [
{ {
type: 'SYNC_PROGRESS', type: 'SYNC_PROGRESS',
payload: { payload: {
index index,
} },
} },
] ],
}); });
} }
} else if ((event.payload as { } else if (
items: unknown[]; (
}).items.length === 1) { event.payload as {
this.broadcast({ // add playlist items: unknown[];
}
).items.length === 1
) {
this.broadcast({
// add playlist
type: 'ADD_SONGS', type: 'ADD_SONGS',
payload: { payload: {
// index: (event.payload as any).index, // index: (event.payload as any).index,
videoList: mapQueueItem((it) => ({ videoList: mapQueueItem(
videoId: it!.videoId, (it) =>
ownerId: this.owner!.id ({
} satisfies VideoData), event.payload!.items!) videoId: it!.videoId,
} ownerId: this.owner!.id,
}) satisfies VideoData,
event.payload!.items!,
),
},
}); });
} }
@ -320,13 +347,17 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'MOVE_SONG', type: 'MOVE_SONG',
payload: { payload: {
fromIndex: (event.payload as { fromIndex: (
fromIndex: number; event.payload as {
}).fromIndex, fromIndex: number;
toIndex: (event.payload as { }
toIndex: number; ).fromIndex,
}).toIndex toIndex: (
} event.payload as {
toIndex: number;
}
).toIndex,
},
}); });
return; return;
} }
@ -334,8 +365,8 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'REMOVE_SONG', type: 'REMOVE_SONG',
payload: { payload: {
index: event.payload as number index: event.payload as number,
} },
}); });
return; return;
} }
@ -343,8 +374,8 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'SYNC_PROGRESS', type: 'SYNC_PROGRESS',
payload: { payload: {
index: event.payload as number index: event.payload as number,
} },
}); });
return; return;
} }
@ -355,7 +386,10 @@ export class Queue {
event.payload = undefined; event.payload = undefined;
} }
if (event.type === 'SET_PLAYER_UI_STATE') { if (event.type === 'SET_PLAYER_UI_STATE') {
if (event.payload as string === 'INACTIVE' && this.videoList.length > 0) { if (
(event.payload as string) === 'INACTIVE' &&
this.videoList.length > 0
) {
return; return;
} }
} }
@ -370,7 +404,7 @@ export class Queue {
store: { store: {
...this.queue.queue.store, ...this.queue.queue.store,
dispatch: this.originalDispatch, dispatch: this.originalDispatch,
} },
}, },
}; };
this.originalDispatch?.call(fakeContext, event); this.originalDispatch?.call(fakeContext, event);
@ -384,20 +418,22 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this.queue.dispatch({ this.queue.dispatch({
type: 'HAS_SHOWN_AUTOPLAY', type: 'HAS_SHOWN_AUTOPLAY',
payload: false payload: false,
}); });
this.queue.dispatch({ this.queue.dispatch({
type: 'SET_HEADER', type: 'SET_HEADER',
payload: getHeaderPayload(), payload: getHeaderPayload(),
}); });
this.queue.dispatch({ this.queue.dispatch({
type: 'CLEAR_STEERING_CHIPS' type: 'CLEAR_STEERING_CHIPS',
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
async syncVideo() { async syncVideo() {
const response = await getMusicQueueRenderer(this._videoList.map((it) => it.videoId)); const response = await getMusicQueueRenderer(
this._videoList.map((it) => it.videoId),
);
if (!response) return false; if (!response) return false;
const items = response.queueDatas.map((it) => it.content); const items = response.queueDatas.map((it) => it.content);
@ -407,10 +443,11 @@ export class Queue {
type: 'UPDATE_ITEMS', type: 'UPDATE_ITEMS',
payload: { payload: {
items: items, items: items,
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId, nextQueueItemId:
this.queue.queue.store.store.getState().queue.nextQueueItemId,
shouldAssignIds: true, shouldAssignIds: true,
currentIndex: -1 currentIndex: -1,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -425,7 +462,9 @@ export class Queue {
const allQueue = document.querySelectorAll('#queue'); const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => { allQueue.forEach((queue) => {
const list = Array.from(queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? []); const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item, index: number | undefined) => { list.forEach((item, index: number | undefined) => {
if (typeof index !== 'number') return; if (typeof index !== 'number') return;
@ -433,14 +472,19 @@ export class Queue {
const id = this._videoList[index]?.ownerId; const id = this._videoList[index]?.ownerId;
const data = this.getProfile(id); const data = this.getProfile(id);
const profile = item.querySelector<HTMLImageElement>('.music-together-owner') ?? document.createElement('img'); const profile =
item.querySelector<HTMLImageElement>('.music-together-owner') ??
document.createElement('img');
profile.classList.add('music-together-owner'); profile.classList.add('music-together-owner');
profile.dataset.id = id; profile.dataset.id = id;
profile.dataset.index = index.toString(); profile.dataset.index = index.toString();
const name = item.querySelector<HTMLElement>('.music-together-name') ?? document.createElement('div'); const name =
item.querySelector<HTMLElement>('.music-together-name') ??
document.createElement('div');
name.classList.add('music-together-name'); name.classList.add('music-together-name');
name.textContent = data?.name ?? t('plugins.music-together.internal.unknown-user'); name.textContent =
data?.name ?? t('plugins.music-together.internal.unknown-user');
if (data) { if (data) {
profile.dataset.thumbnail = data.thumbnail ?? ''; profile.dataset.thumbnail = data.thumbnail ?? '';
@ -463,10 +507,14 @@ export class Queue {
const allQueue = document.querySelectorAll('#queue'); const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => { allQueue.forEach((queue) => {
const list = Array.from(queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? []); const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item) => { list.forEach((item) => {
const profile = item.querySelector<HTMLImageElement>('.music-together-owner'); const profile = item.querySelector<HTMLImageElement>(
'.music-together-owner',
);
const name = item.querySelector<HTMLElement>('.music-together-name'); const name = item.querySelector<HTMLElement>('.music-together-name');
profile?.remove(); profile?.remove();
name?.remove(); name?.remove();

View File

@ -8,7 +8,9 @@ type QueueRendererResponse = {
trackingParams: string; trackingParams: string;
}; };
export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRendererResponse | null> => { export const getMusicQueueRenderer = async (
videoIds: string[],
): Promise<QueueRendererResponse | null> => {
const token = extractToken(); const token = extractToken();
if (!token) return null; if (!token) return null;
@ -35,8 +37,8 @@ export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRe
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Origin': 'https://music.youtube.com', 'Origin': 'https://music.youtube.com',
'Authorization': await getAuthorizationHeader(token), 'Authorization': await getAuthorizationHeader(token),
} },
} },
); );
const text = await response.text(); const text = await response.text();

View File

@ -1,21 +1,26 @@
import { import {
ItemPlaylistPanelVideoRenderer, ItemPlaylistPanelVideoRenderer,
PlaylistPanelVideoWrapperRenderer, PlaylistPanelVideoWrapperRenderer,
QueueItem QueueItem,
} from '@/types/datahost-get-state'; } from '@/types/datahost-get-state';
export const mapQueueItem = <T>(map: (item?: ItemPlaylistPanelVideoRenderer) => T, array: QueueItem[]): T[] => array export const mapQueueItem = <T>(
.map((item) => { map: (item?: ItemPlaylistPanelVideoRenderer) => T,
if ('playlistPanelVideoWrapperRenderer' in item) { array: QueueItem[],
const keys = Object.keys(item.playlistPanelVideoWrapperRenderer!.primaryRenderer) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[]; ): T[] =>
return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]]; array
} .map((item) => {
if ('playlistPanelVideoRenderer' in item) { if ('playlistPanelVideoWrapperRenderer' in item) {
return item.playlistPanelVideoRenderer; const keys = Object.keys(
} item.playlistPanelVideoWrapperRenderer!.primaryRenderer,
) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
console.error('Music Together: Unknown item', item); return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
return undefined; }
}) if ('playlistPanelVideoRenderer' in item) {
.map(map); return item.playlistPanelVideoRenderer;
}
console.error('Music Together: Unknown item', item);
return undefined;
})
.map(map);

View File

@ -10,13 +10,16 @@ export type VideoData = {
}; };
export type Permission = 'host-only' | 'playlist' | 'all'; export type Permission = 'host-only' | 'playlist' | 'all';
export const getDefaultProfile = (connectionID: string, id: string = Date.now().toString()): Profile => { export const getDefaultProfile = (
connectionID: string,
id: string = Date.now().toString(),
): Profile => {
const name = `Guest ${id.slice(0, 4)}`; const name = `Guest ${id.slice(0, 4)}`;
return { return {
id: connectionID, id: connectionID,
handleId: `#music-together:${id}`, handleId: `#music-together:${id}`,
name, name,
thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random` thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
}; };
}; };

View File

@ -7,7 +7,6 @@ import { createStatus } from '../ui/status';
import IconOff from '../icons/off.svg?raw'; import IconOff from '../icons/off.svg?raw';
export type GuestPopupProps = { export type GuestPopupProps = {
onItemClick: (id: string) => void; onItemClick: (id: string) => void;
}; };
@ -33,7 +32,7 @@ export const createGuestPopup = (props: GuestPopupProps) => {
}, },
], ],
anchorAt: 'bottom-right', anchorAt: 'bottom-right',
popupAt: 'top-right' popupAt: 'top-right',
}); });
return { return {

View File

@ -22,7 +22,7 @@ export const createHostPopup = (props: HostPopupProps) => {
element: status.element, element: status.element,
}, },
{ {
type: 'divider' type: 'divider',
}, },
{ {
id: 'music-together-copy-id', id: 'music-together-copy-id',
@ -35,7 +35,9 @@ export const createHostPopup = (props: HostPopupProps) => {
id: 'music-together-permission', id: 'music-together-permission',
type: 'item', type: 'item',
icon: ElementFromHtml(IconTune), icon: ElementFromHtml(IconTune),
text: t('plugins.music-together.menu.set-permission', { permission: t('plugins.music-together.menu.permission.host-only') }), text: t('plugins.music-together.menu.set-permission', {
permission: t('plugins.music-together.menu.permission.host-only'),
}),
onClick: () => props.onItemClick('music-together-permission'), onClick: () => props.onItemClick('music-together-permission'),
}, },
{ {

View File

@ -39,7 +39,7 @@ export const createSettingPopup = (props: SettingPopupProps) => {
}, },
], ],
anchorAt: 'bottom-right', anchorAt: 'bottom-right',
popupAt: 'top-right' popupAt: 'top-right',
}); });
return { return {

View File

@ -7,17 +7,27 @@ import type { Permission, Profile } from '../types';
export const createStatus = () => { export const createStatus = () => {
const element = ElementFromHtml(statusHTML); const element = ElementFromHtml(statusHTML);
const icon = document.querySelector<HTMLImageElement>('ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img'); const icon = document.querySelector<HTMLImageElement>(
'ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img',
);
const profile = element.querySelector<HTMLImageElement>('.music-together-profile')!; const profile = element.querySelector<HTMLImageElement>(
const statusLabel = element.querySelector<HTMLSpanElement>('#music-together-status-label')!; '.music-together-profile',
const permissionLabel = element.querySelector<HTMLMarqueeElement>('#music-together-permission-label')!; )!;
const statusLabel = element.querySelector<HTMLSpanElement>(
'#music-together-status-label',
)!;
const permissionLabel = element.querySelector<HTMLMarqueeElement>(
'#music-together-permission-label',
)!;
profile.src = icon?.src ?? ''; profile.src = icon?.src ?? '';
const setStatus = (status: 'disconnected' | 'host' | 'guest') => { const setStatus = (status: 'disconnected' | 'host' | 'guest') => {
if (status === 'disconnected') { if (status === 'disconnected') {
statusLabel.textContent = t('plugins.music-together.menu.status.disconnected'); statusLabel.textContent = t(
'plugins.music-together.menu.status.disconnected',
);
statusLabel.style.color = 'rgba(255, 255, 255, 0.5)'; statusLabel.style.color = 'rgba(255, 255, 255, 0.5)';
} }
@ -34,17 +44,23 @@ export const createStatus = () => {
const setPermission = (permission: Permission) => { const setPermission = (permission: Permission) => {
if (permission === 'host-only') { if (permission === 'host-only') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.host-only'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.host-only',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)'; permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)';
} }
if (permission === 'playlist') { if (permission === 'playlist') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.playlist'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.playlist',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)'; permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)';
} }
if (permission === 'all') { if (permission === 'all') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.all'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.all',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 1)'; permissionLabel.style.color = 'rgba(255, 255, 255, 1)';
} }
}; };
@ -54,7 +70,9 @@ export const createStatus = () => {
}; };
const setUsers = (users: Profile[]) => { const setUsers = (users: Profile[]) => {
const container = element.querySelector<HTMLDivElement>('.music-together-user-container')!; const container = element.querySelector<HTMLDivElement>(
'.music-together-user-container',
)!;
const empty = element.querySelector<HTMLElement>('.music-together-empty')!; const empty = element.querySelector<HTMLElement>('.music-together-empty')!;
for (const child of Array.from(container.children)) { for (const child of Array.from(container.children)) {
if (child !== empty) child.remove(); if (child !== empty) child.remove();

View File

@ -68,7 +68,9 @@ const observer = new MutationObserver(() => {
if (!menuUrl?.includes('watch?')) { if (!menuUrl?.includes('watch?')) {
menuUrl = undefined; menuUrl = undefined;
// check for podcast // check for podcast
for (const it of document.querySelectorAll('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')) { for (const it of document.querySelectorAll(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
menuUrl = it.getAttribute('href')!; menuUrl = it.getAttribute('href')!;
break; break;

View File

@ -53,10 +53,7 @@ const observePopupContainer = () => {
menu = getSongMenu(); menu = getSongMenu();
} }
if ( if (menu && !menu.contains(slider)) {
menu &&
!menu.contains(slider)
) {
menu.prepend(slider); menu.prepend(slider);
setupSliderListener(); setupSliderListener();
} }

View File

@ -8,7 +8,6 @@ const ignored = {
function overrideAddEventListener() { function overrideAddEventListener() {
// YO WHAT ARE YOU DOING NOW?!?! // YO WHAT ARE YOU DOING NOW?!?!
// Save native addEventListener // Save native addEventListener
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - We know what we're doing // @ts-expect-error - We know what we're doing
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
Element.prototype._addEventListener = Element.prototype.addEventListener; Element.prototype._addEventListener = Element.prototype.addEventListener;

View File

@ -5,13 +5,13 @@ import { onMenu } from './menu';
import { backend } from './main'; import { backend } from './main';
export interface ScrobblerPluginConfig { export interface ScrobblerPluginConfig {
enabled: boolean, enabled: boolean;
/** /**
* Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos) * Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos)
* *
* @default true * @default true
*/ */
scrobbleOtherMedia: boolean, scrobbleOtherMedia: boolean;
scrobblers: { scrobblers: {
lastfm: { lastfm: {
/** /**
@ -19,53 +19,53 @@ export interface ScrobblerPluginConfig {
* *
* @default false * @default false
*/ */
enabled: boolean, enabled: boolean;
/** /**
* Token used for authentication * Token used for authentication
*/ */
token: string | undefined, token: string | undefined;
/** /**
* Session key used for scrobbling * Session key used for scrobbling
*/ */
sessionKey: 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/'
*/ */
apiRoot: string, apiRoot: string;
/** /**
* Last.fm api key registered by @semvis123 * Last.fm api key registered by @semvis123
* *
* @default '04d76faaac8726e60988e14c105d421a' * @default '04d76faaac8726e60988e14c105d421a'
*/ */
apiKey: string, apiKey: string;
/** /**
* Last.fm api secret registered by @semvis123 * Last.fm api secret registered by @semvis123
* *
* @default 'a5d2a36fdf64819290f6982481eaffa2' * @default 'a5d2a36fdf64819290f6982481eaffa2'
*/ */
secret: string, secret: string;
}, };
listenbrainz: { listenbrainz: {
/** /**
* Enable ListenBrainz scrobbling * Enable ListenBrainz scrobbling
* *
* @default false * @default false
*/ */
enabled: boolean, enabled: boolean;
/** /**
* Listenbrainz user token * Listenbrainz user token
*/ */
token: string | undefined, token: string | undefined;
/** /**
* Root of the ListenBrainz API * Root of the ListenBrainz API
* *
* @default 'https://api.listenbrainz.org/1/' * @default 'https://api.listenbrainz.org/1/'
*/ */
apiRoot: string, apiRoot: string;
}, };
} };
} }
export const defaultConfig: ScrobblerPluginConfig = { export const defaultConfig: ScrobblerPluginConfig = {

View File

@ -1,6 +1,9 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import registerCallback, { MediaType, type SongInfo } from '@/providers/song-info'; import registerCallback, {
MediaType,
type SongInfo,
} from '@/providers/song-info';
import { createBackend } from '@/utils'; import { createBackend } from '@/utils';
import { LastFmScrobbler } from './services/lastfm'; import { LastFmScrobbler } from './services/lastfm';
@ -13,14 +16,23 @@ export type SetConfType = (
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>, conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
) => void | Promise<void>; ) => void | Promise<void>;
export const backend = createBackend<{ export const backend = createBackend<
config?: ScrobblerPluginConfig; {
window?: BrowserWindow; config?: ScrobblerPluginConfig;
enabledScrobblers: Map<string, ScrobblerBase>; window?: BrowserWindow;
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow): void; enabledScrobblers: Map<string, ScrobblerBase>;
createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<void>; toggleScrobblers(
setConfig?: SetConfType; config: ScrobblerPluginConfig,
}, ScrobblerPluginConfig>({ window: BrowserWindow,
): void;
createSessions(
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): Promise<void>;
setConfig?: SetConfType;
},
ScrobblerPluginConfig
>({
enabledScrobblers: new Map(), enabledScrobblers: new Map(),
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) { toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) {
@ -30,7 +42,10 @@ export const backend = createBackend<{
this.enabledScrobblers.delete('lastfm'); this.enabledScrobblers.delete('lastfm');
} }
if (config.scrobblers.listenbrainz && config.scrobblers.listenbrainz.enabled) { if (
config.scrobblers.listenbrainz &&
config.scrobblers.listenbrainz.enabled
) {
this.enabledScrobblers.set('listenbrainz', new ListenbrainzScrobbler()); this.enabledScrobblers.set('listenbrainz', new ListenbrainzScrobbler());
} else { } else {
this.enabledScrobblers.delete('listenbrainz'); this.enabledScrobblers.delete('listenbrainz');
@ -45,12 +60,8 @@ export const backend = createBackend<{
} }
}, },
async start({ async start({ getConfig, setConfig, window }) {
getConfig, const config = (this.config = await getConfig());
setConfig,
window,
}) {
const config = this.config = await getConfig();
// This will store the timeout that will trigger addScrobble // This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined; let scrobbleTimer: NodeJS.Timeout | undefined;
@ -65,21 +76,38 @@ 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.scrobbleOtherMedia && (songInfo.mediaType !== MediaType.Audio && songInfo.mediaType !== MediaType.OriginalMusicVideo)) { if (
!configNonnull.scrobbleOtherMedia &&
songInfo.mediaType !== MediaType.Audio &&
songInfo.mediaType !== MediaType.OriginalMusicVideo
) {
return; return;
} }
// Scrobble when the song is halfway through, or has passed the 4-minute mark // Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60); const scrobbleTime = Math.min(
Math.ceil(songInfo.songDuration / 2),
4 * 60,
);
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) { if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen // Scrobble still needs to happen
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000; const timeToWait =
scrobbleTimer = setTimeout((info, config) => { (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
this.enabledScrobblers.forEach((scrobbler) => scrobbler.addScrobble(info, config, setConfig)); scrobbleTimer = setTimeout(
}, timeToWait, songInfo, configNonnull); (info, config) => {
this.enabledScrobblers.forEach((scrobbler) =>
scrobbler.addScrobble(info, config, setConfig),
);
},
timeToWait,
songInfo,
configNonnull,
);
} }
this.enabledScrobblers.forEach((scrobbler) => scrobbler.setNowPlaying(songInfo, configNonnull, setConfig)); this.enabledScrobblers.forEach((scrobbler) =>
scrobbler.setNowPlaying(songInfo, configNonnull, setConfig),
);
} }
}); });
}, },
@ -88,11 +116,15 @@ export const backend = createBackend<{
this.enabledScrobblers.clear(); this.enabledScrobblers.clear();
this.toggleScrobblers(newConfig, this.window!); this.toggleScrobblers(newConfig, this.window!);
for (const [scrobblerName, scrobblerConfig] of Object.entries(newConfig.scrobblers)) { for (const [scrobblerName, scrobblerConfig] of Object.entries(
newConfig.scrobblers,
)) {
if (scrobblerConfig.enabled) { if (scrobblerConfig.enabled) {
const scrobbler = this.enabledScrobblers.get(scrobblerName); const scrobbler = this.enabledScrobblers.get(scrobblerName);
if ( if (
this.config?.scrobblers?.[scrobblerName as keyof typeof newConfig.scrobblers]?.enabled !== scrobblerConfig.enabled && this.config?.scrobblers?.[
scrobblerName as keyof typeof newConfig.scrobblers
]?.enabled !== scrobblerConfig.enabled &&
scrobbler && scrobbler &&
!scrobbler.isSessionCreated(newConfig) && !scrobbler.isSessionCreated(newConfig) &&
this.setConfig this.setConfig
@ -103,6 +135,5 @@ export const backend = createBackend<{
} }
this.config = newConfig; this.config = newConfig;
} },
}); });

View File

@ -11,7 +11,11 @@ import { SetConfType, backend } from './main';
import type { MenuContext } from '@/types/contexts'; import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu'; import type { MenuTemplate } from '@/menu';
async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) { async function promptLastFmOptions(
options: ScrobblerPluginConfig,
setConfig: SetConfType,
window: BrowserWindow,
) {
const output = await prompt( const output = await prompt(
{ {
title: t('plugins.scrobbler.menu.lastfm.api-settings'), title: t('plugins.scrobbler.menu.lastfm.api-settings'),
@ -22,16 +26,16 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
label: t('plugins.scrobbler.prompt.lastfm.api-key'), label: t('plugins.scrobbler.prompt.lastfm.api-key'),
value: options.scrobblers.lastfm?.apiKey, value: options.scrobblers.lastfm?.apiKey,
inputAttrs: { inputAttrs: {
type: 'text' type: 'text',
} },
}, },
{ {
label: t('plugins.scrobbler.prompt.lastfm.api-secret'), label: t('plugins.scrobbler.prompt.lastfm.api-secret'),
value: options.scrobblers.lastfm?.secret, value: options.scrobblers.lastfm?.secret,
inputAttrs: { inputAttrs: {
type: 'text' type: 'text',
} },
} },
], ],
resizable: true, resizable: true,
height: 360, height: 360,
@ -53,7 +57,11 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
} }
} }
async function promptListenbrainzOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) { async function promptListenbrainzOptions(
options: ScrobblerPluginConfig,
setConfig: SetConfType,
window: BrowserWindow,
) {
const output = await prompt( const output = await prompt(
{ {
title: t('plugins.scrobbler.prompt.listenbrainz.token.title'), title: t('plugins.scrobbler.prompt.listenbrainz.token.title'),

View File

@ -5,9 +5,20 @@ import type { SongInfo } from '@/providers/song-info';
export abstract class ScrobblerBase { export abstract class ScrobblerBase {
public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean; public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean;
public abstract createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig>; public abstract createSession(
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): Promise<ScrobblerPluginConfig>;
public abstract setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; public abstract setNowPlaying(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void;
public abstract addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; public abstract addScrobble(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void;
} }

View File

@ -81,7 +81,11 @@ export class LastFmScrobbler extends ScrobblerBase {
return config; return config;
} }
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override setNowPlaying(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void {
if (!config.scrobblers.lastfm.sessionKey) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -93,7 +97,11 @@ export class LastFmScrobbler extends ScrobblerBase {
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override addScrobble(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void {
if (!config.scrobblers.lastfm.sessionKey) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -101,7 +109,9 @@ export class LastFmScrobbler extends ScrobblerBase {
// This adds one scrobbled song to last.fm // This adds one scrobbled song to last.fm
const data = { const data = {
method: 'track.scrobble', method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000), timestamp: Math.trunc(
(Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000,
),
}; };
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
@ -195,8 +205,7 @@ 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
let sig = ''; let sig = '';
Object Object.entries(parameters)
.entries(parameters)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.forEach(([key, value]) => { .forEach(([key, value]) => {
if (key === 'format') { if (key === 'format') {
@ -212,12 +221,8 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
const createToken = async ({ const createToken = async ({
scrobblers: { scrobblers: {
lastfm: { lastfm: { apiKey, apiRoot, secret },
apiKey, },
apiRoot,
secret,
}
}
}: ScrobblerPluginConfig) => { }: ScrobblerPluginConfig) => {
// Creates and stores the auth token // Creates and stores the auth token
const data: { const data: {
@ -240,7 +245,10 @@ const createToken = async ({
let authWindowOpened = false; let authWindowOpened = false;
let latestAuthResult = false; let latestAuthResult = false;
const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWindow) => { const authenticate = async (
config: ScrobblerPluginConfig,
mainWindow: BrowserWindow,
) => {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
if (!authWindowOpened) { if (!authWindowOpened) {
authWindowOpened = true; authWindowOpened = true;
@ -266,9 +274,10 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
const url = new URL(newUrl); const url = new URL(newUrl);
if (url.hostname.endsWith('last.fm')) { if (url.hostname.endsWith('last.fm')) {
if (url.pathname === '/api/auth') { if (url.pathname === '/api/auth') {
const isApproveScreen = await browserWindow.webContents.executeJavaScript( const isApproveScreen =
'!!document.getElementsByName(\'confirm\').length' (await browserWindow.webContents.executeJavaScript(
) as boolean; "!!document.getElementsByName('confirm').length",
)) as boolean;
// successful authentication // successful authentication
if (!isApproveScreen) { if (!isApproveScreen) {
resolve(true); resolve(true);
@ -287,7 +296,7 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
dialog.showMessageBox({ dialog.showMessageBox({
title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'), title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'),
message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'), message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'),
type: 'error' type: 'error',
}); });
} }
authWindowOpened = false; authWindowOpened = false;

View File

@ -29,12 +29,22 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
return true; return true;
} }
override createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> { override createSession(
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): Promise<ScrobblerPluginConfig> {
return Promise.resolve(config); return Promise.resolve(config);
} }
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override setNowPlaying(
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) { songInfo: SongInfo,
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): void {
if (
!config.scrobblers.listenbrainz.apiRoot ||
!config.scrobblers.listenbrainz.token
) {
return; return;
} }
@ -42,8 +52,15 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
submitListen(body, config); submitListen(body, config);
} }
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override addScrobble(
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) { songInfo: SongInfo,
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): void {
if (
!config.scrobblers.listenbrainz.apiRoot ||
!config.scrobblers.listenbrainz.token
) {
return; return;
} }
@ -54,7 +71,10 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
} }
} }
function createRequestBody(listenType: string, songInfo: SongInfo): ListenbrainzRequestBody { function createRequestBody(
listenType: string,
songInfo: SongInfo,
): ListenbrainzRequestBody {
const trackMetadata = { const trackMetadata = {
artist_name: songInfo.artist, artist_name: songInfo.artist,
track_name: songInfo.title, track_name: songInfo.title,
@ -64,7 +84,7 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
submission_client: 'YouTube Music Desktop App - Scrobbler Plugin', submission_client: 'YouTube Music Desktop App - Scrobbler Plugin',
origin_url: songInfo.url, origin_url: songInfo.url,
duration: songInfo.songDuration, duration: songInfo.songDuration,
} },
}; };
return { return {
@ -72,19 +92,23 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
payload: [ payload: [
{ {
track_metadata: trackMetadata, track_metadata: trackMetadata,
} },
] ],
}; };
} }
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) { function submitListen(
net.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens', body: ListenbrainzRequestBody,
{ config: ScrobblerPluginConfig,
) {
net
.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
headers: { headers: {
'Authorization': 'Token ' + config.scrobblers.listenbrainz.token, 'Authorization': 'Token ' + config.scrobblers.listenbrainz.token,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} },
}).catch(console.error); })
.catch(console.error);
} }

View File

@ -176,15 +176,17 @@ declare module '@jellybrick/mpris-service' {
setActivePlaylist(playlistId: string): void; setActivePlaylist(playlistId: string): void;
} }
interface MprisInterface extends dbusInterface.Interface { export interface MprisInterface extends dbusInterface.Interface {
setProperty(property: string, valuePlain: unknown): void; setProperty(property: string, valuePlain: unknown): void;
} }
interface RootInterface {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface RootInterface {}
interface PlayerInterface {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PlayerInterface {}
interface TracklistInterface { export interface TracklistInterface {
TrackListReplaced(tracks: Track[]): void; TrackListReplaced(tracks: Track[]): void;
TrackAdded(afterTrack: string): void; TrackAdded(afterTrack: string): void;
@ -192,7 +194,7 @@ declare module '@jellybrick/mpris-service' {
TrackRemoved(trackId: string): void; TrackRemoved(trackId: string): void;
} }
interface PlaylistsInterface { export interface PlaylistsInterface {
PlaylistChanged(playlist: unknown[]): void; PlaylistChanged(playlist: unknown[]): void;
setActivePlaylistId(playlistId: string): void; setActivePlaylistId(playlistId: string): void;

View File

@ -192,10 +192,13 @@ function registerMPRIS(win: BrowserWindow) {
return; return;
} }
const currentPosition = queue.items?.findIndex((it) => const currentPosition =
it?.playlistPanelVideoRenderer?.selected || queue.items?.findIndex(
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer?.playlistPanelVideoRenderer?.selected (it) =>
) ?? 0; it?.playlistPanelVideoRenderer?.selected ||
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer
?.playlistPanelVideoRenderer?.selected,
) ?? 0;
player.canGoPrevious = currentPosition !== 0; player.canGoPrevious = currentPosition !== 0;
let hasNext: boolean; let hasNext: boolean;

View File

@ -16,20 +16,24 @@ export default createPlugin<
restartNeeded: false, restartNeeded: false,
renderer: { renderer: {
start() { start() {
waitForElement<HTMLElement>('#dislike-button-renderer').then((dislikeBtn) => { waitForElement<HTMLElement>('#dislike-button-renderer').then(
this.observer = new MutationObserver(() => { (dislikeBtn) => {
if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') { this.observer = new MutationObserver(() => {
document if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') {
.querySelector<HTMLButtonElement>('tp-yt-paper-icon-button.next-button') document
?.click(); .querySelector<HTMLButtonElement>(
} 'tp-yt-paper-icon-button.next-button',
}); )
this.observer.observe(dislikeBtn, { ?.click();
attributes: true, }
childList: false, });
subtree: false, this.observer.observe(dislikeBtn, {
}); attributes: true,
}); childList: false,
subtree: false,
});
},
);
}, },
stop() { stop() {
this.observer?.disconnect(); this.observer?.disconnect();

View File

@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-require-imports */
// eslint-disable-next-line no-undef
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// eslint-disable-next-line no-undef
const { sortSegments } = require('../segments'); const { sortSegments } = require('../segments');
test('Segment sorting', () => { test('Segment sorting', () => {

View File

@ -31,8 +31,12 @@ export const menu = async ({
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.scale.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'scale', checked: config.lineEffect === 'scale',
click() { click() {
@ -42,8 +46,12 @@ export const menu = async ({
}, },
}, },
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.offset.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'offset', checked: config.lineEffect === 'offset',
click() { click() {
@ -53,8 +61,12 @@ export const menu = async ({
}, },
}, },
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.focus.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'focus', checked: config.lineEffect === 'focus',
click() { click() {
@ -125,7 +137,9 @@ export const menu = async ({
}, },
{ {
label: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.label'), label: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.label'),
toolTip: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.tooltip'), toolTip: t(
'plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.tooltip',
),
type: 'checkbox', type: 'checkbox',
checked: config.showLyricsEvenIfInexact, checked: config.showLyricsEvenIfInexact,
click(item) { click(item) {

View File

@ -28,7 +28,7 @@ export const LyricsContainer = () => {
const info = getSongInfo(); const info = getSongInfo();
await makeLyricsRequest(info).catch((err) => { await makeLyricsRequest(info).catch((err) => {
setError(`${err}`); setError(String(err));
}); });
}; };

View File

@ -14,12 +14,15 @@ import type { SyncedLyricsPluginConfig } from '../types';
export let _ytAPI: YoutubePlayer | null = null; export let _ytAPI: YoutubePlayer | null = null;
export const renderer = createRenderer<{ export const renderer = createRenderer<
observerCallback: MutationCallback; {
observer?: MutationObserver; observerCallback: MutationCallback;
videoDataChange: () => Promise<void>; observer?: MutationObserver;
updateTimestampInterval?: NodeJS.Timeout | string | number; videoDataChange: () => Promise<void>;
}, SyncedLyricsPluginConfig>({ updateTimestampInterval?: NodeJS.Timeout | string | number;
},
SyncedLyricsPluginConfig
>({
onConfigChange(newConfig) { onConfigChange(newConfig) {
setConfig(newConfig); setConfig(newConfig);
}, },
@ -57,9 +60,7 @@ export const renderer = createRenderer<{
); );
} }
this.observer ??= new MutationObserver( this.observer ??= new MutationObserver(this.observerCallback);
this.observerCallback,
);
// Force the lyrics tab to be enabled at all times. // Force the lyrics tab to be enabled at all times.
this.observer.disconnect(); this.observer.disconnect();

View File

@ -8,21 +8,16 @@ import { setDebugInfo, setLineLyrics } from '../components/LyricsContainer';
import type { SongInfo } from '@/providers/song-info'; import type { SongInfo } from '@/providers/song-info';
import type { LineLyrics, LRCLIBSearchResponse } from '../../types'; import type { LineLyrics, LRCLIBSearchResponse } from '../../types';
// prettier-ignore
export const [isInstrumental, setIsInstrumental] = createSignal(false); export const [isInstrumental, setIsInstrumental] = createSignal(false);
// prettier-ignore
export const [isFetching, setIsFetching] = createSignal(false); export const [isFetching, setIsFetching] = createSignal(false);
// prettier-ignore
export const [hadSecondAttempt, setHadSecondAttempt] = createSignal(false); export const [hadSecondAttempt, setHadSecondAttempt] = createSignal(false);
// prettier-ignore
export const [differentDuration, setDifferentDuration] = createSignal(false); export const [differentDuration, setDifferentDuration] = createSignal(false);
// eslint-disable-next-line prefer-const
export const extractTimeAndText = ( export const extractTimeAndText = (
line: string, line: string,
index: number, index: number,
): LineLyrics | null => { ): LineLyrics | null => {
const groups = /\[(\d+):(\d+)\.(\d+)\](.+)/.exec(line); const groups = /\[(\d+):(\d+)\.(\d+)](.+)/.exec(line);
if (!groups) return null; if (!groups) return null;
const [, rMinutes, rSeconds, rMillis, text] = groups; const [, rMinutes, rSeconds, rMillis, text] = groups;
@ -32,14 +27,13 @@ export const extractTimeAndText = (
parseInt(rMillis), parseInt(rMillis),
]; ];
// prettier-ignore const timeInMs = minutes * 60 * 1000 + seconds * 1000 + millis;
const timeInMs = (minutes * 60 * 1000) + (seconds * 1000) + millis;
return { return {
index, index,
timeInMs, timeInMs,
time: `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${millis}`, time: `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${millis}`,
text: text?.trim() ?? config()!.defaultTextString, text: text?.trim() || config()!.defaultTextString,
status: 'upcoming', status: 'upcoming',
duration: 0, duration: 0,
}; };
@ -81,7 +75,6 @@ export const getLyricsList = async (
track_name: songData.title, track_name: songData.title,
}); });
if (songData.album) { if (songData.album) {
query.set('album_name', songData.album); query.set('album_name', songData.album);
} }
@ -94,7 +87,7 @@ export const getLyricsList = async (
return null; return null;
} }
let data = await response.json() as LRCLIBSearchResponse; let data = (await response.json()) as LRCLIBSearchResponse;
if (!data || !Array.isArray(data)) { if (!data || !Array.isArray(data)) {
setDebugInfo('Unexpected server response.'); setDebugInfo('Unexpected server response.');
return null; return null;
@ -124,7 +117,7 @@ export const getLyricsList = async (
setHadSecondAttempt(true); setHadSecondAttempt(true);
} }
const filteredResults = []; const filteredResults: LRCLIBSearchResponse = [];
for (const item of data) { for (const item of data) {
const { artist } = songData; const { artist } = songData;
const { artistName } = item; const { artistName } = item;
@ -133,7 +126,10 @@ export const getLyricsList = async (
const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim());
const permutations = artists.flatMap((artistA) => const permutations = artists.flatMap((artistA) =>
itemArtists.map((artistB) => [artistA.toLowerCase(), artistB.toLowerCase()]) itemArtists.map((artistB) => [
artistA.toLowerCase(),
artistB.toLowerCase(),
]),
); );
const ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y))); const ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y)));
@ -154,7 +150,7 @@ export const getLyricsList = async (
return null; return null;
} }
setDebugInfo(JSON.stringify(closestResult, null, 4)); setDebugInfo(JSON.stringify(closestResult, null, 4));
if (Math.abs(closestResult.duration - duration) > 15) { if (Math.abs(closestResult.duration - duration) > 15) {
return null; return null;
@ -179,8 +175,8 @@ export const getLyricsList = async (
// Add a blank line at the beginning // Add a blank line at the beginning
raw.unshift('[0:0.0] '); raw.unshift('[0:0.0] ');
const syncedLyricList = raw.reduce<LineLyrics[]>((acc, line, index) => { const syncedLyricList = raw.reduce<LineLyrics[]>((acc, line) => {
const syncedLine = extractTimeAndText(line, index); const syncedLine = extractTimeAndText(line, acc.length);
if (syncedLine) { if (syncedLine) {
acc.push(syncedLine); acc.push(syncedLine);
} }

View File

@ -7,8 +7,11 @@ import type { SyncedLyricsPluginConfig } from '../types';
export const [isVisible, setIsVisible] = createSignal<boolean>(false); export const [isVisible, setIsVisible] = createSignal<boolean>(false);
export const [config, setConfig] = createSignal<SyncedLyricsPluginConfig | null>(null); export const [config, setConfig] =
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(null); createSignal<SyncedLyricsPluginConfig | null>(null);
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(
null,
);
export const LyricsRenderer = () => { export const LyricsRenderer = () => {
return ( return (

View File

@ -1,4 +1,5 @@
import { nativeImage } from 'electron'; import { type NativeImage, nativeImage, nativeTheme } from 'electron';
import { Jimp, JimpMime } from 'jimp';
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack'; import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack'; import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
@ -19,48 +20,13 @@ export default createPlugin({
enabled: false, enabled: false,
}, },
backend({ window }) { async backend({ window }) {
let currentSongInfo: SongInfo; let currentSongInfo: SongInfo;
const { playPause, next, previous } = getSongControls(window); const { playPause, next, previous } = getSongControls(window);
const setThumbar = (songInfo: SongInfo) => {
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Win32 require full rewrite of components
window.setThumbarButtons([
{
tooltip: 'Previous',
icon: nativeImage.createFromPath(get('previous')),
click() {
previous();
},
},
{
tooltip: 'Play/Pause',
// Update icon based on play state
icon: nativeImage.createFromPath(
songInfo.isPaused ? get('play') : get('pause'),
),
click() {
playPause();
},
},
{
tooltip: 'Next',
icon: nativeImage.createFromPath(get('next')),
click() {
next();
},
},
]);
};
// Util // Util
const get = (kind: keyof typeof mediaIcons): string => { const getImagePath = (kind: keyof typeof mediaIcons): string => {
switch (kind) { switch (kind) {
case 'play': case 'play':
return playIcon; return playIcon;
@ -75,6 +41,67 @@ export default createPlugin({
} }
}; };
const getNativeImage = async (
kind: keyof typeof mediaIcons,
): Promise<NativeImage> => {
const imagePath = getImagePath(kind);
if (imagePath) {
console.log('imagePath', imagePath);
const jimpImageBuffer = await Jimp.read(imagePath).then((img) => {
if (imagePath && nativeTheme.shouldUseDarkColors) {
return img.invert().getBuffer(JimpMime.png);
}
return img.getBuffer(JimpMime.png);
});
return nativeImage.createFromBuffer(jimpImageBuffer);
}
// return empty image
return nativeImage.createEmpty();
};
const images = {
play: await getNativeImage('play'),
pause: await getNativeImage('pause'),
next: await getNativeImage('next'),
previous: await getNativeImage('previous'),
};
const setThumbar = (songInfo: SongInfo) => {
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Win32 require full rewrite of components
window.setThumbarButtons([
{
tooltip: 'Previous',
icon: images.previous,
click() {
previous();
},
},
{
tooltip: 'Play/Pause',
// Update icon based on play state
icon: songInfo.isPaused ? images.play : images.pause,
click() {
playPause();
},
},
{
tooltip: 'Next',
icon: images.next,
click() {
next();
},
},
]);
};
registerCallback((songInfo) => { registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show') // Update currentsonginfo for win.on('show')
currentSongInfo = songInfo; currentSongInfo = songInfo;
@ -83,8 +110,6 @@ export default createPlugin({
}); });
// Need to set thumbar again after win.show // Need to set thumbar again after win.show
window.on('show', () => { window.on('show', () => setThumbar(currentSongInfo));
setThumbar(currentSongInfo);
});
}, },
}); });

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