Compare commits

...

60 Commits

Author SHA1 Message Date
789a30312b fix(reviewdog): checkout PR HEAD 2025-09-06 06:38:36 +09:00
623d97b1e2 fix(reviewdog): run eslint for changed files 2025-09-06 06:28:52 +09:00
4ed97f0145 fix: action should catch errors 2025-09-06 06:22:19 +09:00
77a2bbf02a fix(action): fix permission 2025-09-06 06:09:03 +09:00
7a9a1531d4 fix(action): fix auth 2025-09-06 05:35:59 +09:00
16b59698d6 fix(action): fix node version 2025-09-06 05:30:58 +09:00
a85a2e0c58 fix(action): fix install deps 2025-09-06 05:27:38 +09:00
97f1a20a4f fix(actions): fix mistake 2025-09-06 05:19:21 +09:00
8dbe151ddd fix(audio-compressor): real-time behavior and duplicated audio bug (#3786)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-06 05:15:47 +09:00
87144e03c2 fix(actions): fix node / pnpm version 2025-09-06 05:13:51 +09:00
2046b253e3 fix: Added Min height and width to the window which doesnt breaks the UI responsiveness (#3602) 2025-09-06 05:04:16 +09:00
c068e11fc5 feat(actions): add reviewdog 2025-09-06 04:54:53 +09:00
10384b6c4c chore(eslint): added new eslint rule (for jsx) 2025-09-06 04:51:31 +09:00
4d83bd587d chore(deps): update dependency discord-api-types to v0.38.23 (#3833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 02:55:56 +09:00
aae523989b feat(plugin): Custom output device plugin (#3789)
Co-authored-by: Angelos Bouklis <me@arjix.dev>
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-06 02:54:07 +09:00
afacec973b chore(deps): update dependency @babel/runtime to v7.28.4 (#3831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 01:20:54 +09:00
75fb51e290 chore(deps): update eslint monorepo to v9.35.0 (#3829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 01:18:02 +09:00
cb85048af4 feat(api-server): Add websocket as /api/v1/ws route (#3707)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-06 01:17:32 +09:00
8b10872e83 feat(api-server): Improved api-server volume and like/dislike state (#3592)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: rewhex <gitea@cluser.local>
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-05 23:59:39 +09:00
96ea114335 fix: pnpm-lock.yaml 2025-09-05 23:02:15 +09:00
7c1c3ef28d Merge remote-tracking branch 'origin/master' 2025-09-05 22:45:43 +09:00
7a7ad4261c fix(deps): update dependency i18next to v25.5.2 (#3826)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 22:45:33 +09:00
c0dbc204a0 fix(deps): update dependency virtua to v0.42.2 (#3827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 22:45:19 +09:00
68e63f809c fix: apply fix from eslint 2025-09-05 22:43:34 +09:00
4b188ec205 chore(i18n): Translated using Weblate (Thai)
Currently translated at 100.0% (437 of 437 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/th/
2025-09-05 15:07:03 +02:00
23013cddb9 fix(exponential-volume): volume desync bug (#3787)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-05 20:53:07 +09:00
588b84ecd0 feat(synced-lyrics): thai romanization (#3618)
Co-authored-by: Angelos Bouklis <me@arjix.dev>
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-05 16:30:39 +09:00
fd68c204f6 chore(i18n): Translated using Weblate (Swedish)
Currently translated at 100.0% (433 of 433 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/sv/
2025-09-05 06:58:38 +00:00
313bb6e43f chore(i18n): Translated using Weblate (Swedish)
Currently translated at 100.0% (433 of 433 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/sv/
2025-09-05 06:58:37 +00:00
0a6f244035 chore(i18n): Translated using Weblate (Swedish)
Currently translated at 100.0% (433 of 433 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/sv/
2025-09-05 06:58:36 +00:00
8e4e2c42f6 feat(discord): add option to display artist/title in status (#3692)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-05 15:58:26 +09:00
f31053cf3c chore(deps): update playwright monorepo to v1.55.0 (#3819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:43:02 +09:00
d5758790c0 fix(deps): update dependency i18next to v25.5.1 (#3820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:42:52 +09:00
bbd243a534 fix(deps): update dependency virtua to v0.42.0 (#3821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:42:41 +09:00
2fc0d6f3b0 fix(deps): update dependency zod to v4.1.5 (#3822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:42:31 +09:00
6b15018a9b chore(deps): update eslint monorepo to v9.34.0 (#3818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:42:24 +09:00
acc977db7c chore(deps): update actions/setup-node action to v5 (#3823)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:42:03 +09:00
270100a14c chore(deps): update dependency electron to v38 (#3824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:41:43 +09:00
094e6fa2d6 chore(deps): update dependency @stylistic/eslint-plugin to v5.3.1 (#3817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:40:46 +09:00
9f81f7001c fix(deps): update dependency serve to v14.2.5 (#3816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:40:21 +09:00
lew
e6c78dd5e0 feat(discord): add song & artist URLs to rich presence (#3737)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2025-09-05 15:32:31 +09:00
b64e1394ae fix: fix #3621 (#3774) 2025-09-05 15:32:15 +09:00
dcc611c7d0 feat(refactor): PluginDefinition::platform (#3665) 2025-09-05 15:28:17 +09:00
d329076b52 chore(docs): update copyright footer year (#3792) 2025-09-05 15:10:28 +09:00
1435559a56 chore(deps): update dependency vite-plugin-inspect to v11.3.3 (#3814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:08:36 +09:00
1feeeedf10 fix(deps): update dependency @floating-ui/dom to v1.7.4 (#3815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:07:50 +09:00
443d716e45 fix(deps): update dependency @ghostery/adblocker-electron to v2.11.6 (#3770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:01:46 +09:00
3d65a96e38 chore(deps): update dependency discord-api-types to v0.38.22 (#3813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:01:37 +09:00
e20f3fe24c chore(deps): update dependency @types/semver to v7.7.1 (#3812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 15:01:30 +09:00
5d3afb52d8 fix(deps): update dependency @ghostery/adblocker-electron-preload to v2.11.6 (#3771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:59:44 +09:00
5e0341c8d5 fix(deps): update dependency @hono/node-server to v1.19.1 (#3759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:58:08 +09:00
b84a8c512a chore(deps): update dependency typescript-eslint to v8.42.0 (#3761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:57:52 +09:00
cd0f4bbc1d chore(deps): update dependency node-gyp to v11.4.2 (#3772)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:57:38 +09:00
e37367c5e5 chore(deps): update actions/checkout action to v5 (#3757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:55:52 +09:00
8a765be912 chore(deps): update dependency vite to v7.1.5 (#3760)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:54:47 +09:00
a213dae14d fix(deps): update dependency hono to v4.9.6 [security] (#3807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:50:33 +09:00
40c429f3c1 chore(deps): update dependency electron to v37.3.1 [security] (#3806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 14:50:13 +09:00
3125520e68 chore(deps): bump hono from 4.9.2 to 4.9.6 (#3805)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 14:49:48 +09:00
1c57fec016 chore(i18n): Translated using Weblate (Swedish)
Currently translated at 37.6% (163 of 433 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/sv/
2025-09-04 20:01:57 +02:00
2708b4fffc chore(i18n): Translated using Weblate (Greek)
Currently translated at 100.0% (433 of 433 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/el/
2025-09-02 19:22:57 +02:00
107 changed files with 2297 additions and 853 deletions

View File

@ -18,7 +18,7 @@ jobs:
os: [ macos-latest, ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
@ -28,14 +28,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
@ -91,7 +91,7 @@ jobs:
if: github.repository == 'th-ch/youtube-music' && github.ref == 'refs/heads/master'
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
@ -103,14 +103,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}

View File

@ -15,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Dependency Review"
uses: actions/dependency-review-action@v4

41
.github/workflows/reviewdog.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: reviewdog
on: [pull_request_target]
env:
NODE_VERSION: "22.x"
jobs:
eslint:
name: runner / eslint
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup NodeJS
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: reviewdog/action-eslint@v1.34.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review # Change reporter.
eslint_flags: './src'
fail_level: error

View File

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

View File

@ -31,11 +31,19 @@ export default tsEslint.config(
rules: {
'stylistic/arrow-parens': ['error', 'always'],
'stylistic/object-curly-spacing': ['error', 'always'],
'stylistic/jsx-pascal-case': 'error',
'stylistic/jsx-curly-spacing': ['error', { when: 'never', children: true }],
'stylistic/jsx-sort-props': 'error',
'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',
'@typescript-eslint/consistent-type-imports': ['error', {
fixStyle: 'inline-type-imports',
prefer: 'type-imports',
disallowTypeAnnotations: false,
}],
'importPlugin/first': 'error',
'importPlugin/newline-after-import': 'off',
'importPlugin/no-default-export': 'off',

View File

@ -14,16 +14,16 @@
"url": "https://github.com/th-ch/youtube-music"
},
"scripts": {
"test": "playwright test",
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
"build": "electron-vite build",
"test": "pnpm playwright test",
"test:debug": "pnpm cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
"build": "pnpm electron-vite build",
"vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
"start": "electron-vite preview",
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
"dev": "cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev --watch",
"dev:renderer": "cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev",
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
"start": "pnpm electron-vite preview",
"start:debug": "pnpm cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
"dev": "pnpm cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev --watch",
"dev:renderer": "pnpm cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev",
"dev:debug": "pnpm cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
"clean": "pnpm del-cli dist && pnpm del-cli pack && pnpm del-cli .vite-inspect",
"dist": "pnpm clean && pnpm build && pnpm electron-builder --win --mac --linux -p never",
"dist:linux": "pnpm clean && pnpm build && pnpm electron-builder --linux -p never",
"dist:linux:deb-arm64": "pnpm clean && pnpm build && pnpm electron-builder --linux deb:arm64 -p never",
@ -32,12 +32,12 @@
"dist:mac:arm64": "pnpm clean && pnpm build && pnpm electron-builder --mac dmg:arm64 -p never",
"dist:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p never",
"dist:win:x64": "pnpm clean && pnpm build && pnpm electron-builder --win nsis-web:x64 -p never",
"lint": "eslint .",
"changelog": "npx --yes auto-changelog",
"lint": "pnpm eslint ./src",
"changelog": "pnpm dlx --yes auto-changelog",
"release:linux": "pnpm clean && pnpm build && pnpm electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "pnpm clean && pnpm build && pnpm electron-builder --mac -p always",
"release:win": "pnpm clean && pnpm build && pnpm electron-builder --win -p always",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "pnpm tsc -p tsconfig.json --noEmit"
},
"engines": {
"node": ">=22",
@ -45,30 +45,34 @@
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.1.2",
"node-gyp": "11.3.0",
"vite": "npm:rolldown-vite@7.1.5",
"node-gyp": "11.4.2",
"xml2js": "0.6.2",
"node-fetch": "3.3.2",
"@electron/universal": "3.0.1",
"@babel/runtime": "7.28.3"
"@babel/runtime": "7.28.4"
},
"patchedDependencies": {
"vudio@2.1.1": "patches/vudio@2.1.1.patch",
"@malept/flatpak-bundler@0.4.0": "patches/@malept__flatpak-bundler@0.4.0.patch",
"kuromoji@0.1.2": "patches/kuromoji@0.1.2.patch",
"file-type@16.5.4": "patches/file-type@16.5.4.patch"
"file-type@16.5.4": "patches/file-type@16.5.4.patch",
"electron-is@3.0.0": "patches/electron-is@3.0.0.patch"
},
"neverBuiltDependencies": []
},
"dependencies": {
"@dehoist/romanize-thai": "1.0.0",
"@electron-toolkit/tsconfig": "1.0.1",
"@electron/remote": "2.1.3",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0",
"@floating-ui/dom": "1.7.3",
"@floating-ui/dom": "1.7.4",
"@foobar404/wave": "2.0.5",
"@ghostery/adblocker-electron": "2.11.3",
"@ghostery/adblocker-electron-preload": "2.11.3",
"@hono/node-server": "1.18.2",
"@ghostery/adblocker-electron": "2.11.6",
"@ghostery/adblocker-electron-preload": "2.11.6",
"@hono/node-server": "1.19.1",
"@hono/node-ws": "1.2.0",
"@hono/swagger-ui": "0.5.2",
"@hono/zod-openapi": "1.1.0",
"@hono/zod-validator": "0.7.2",
@ -100,10 +104,10 @@
"filenamify": "6.0.0",
"hanja": "1.1.5",
"happy-dom": "18.0.1",
"hono": "4.9.2",
"hono": "4.9.6",
"howler": "2.2.4",
"html-to-text": "9.0.5",
"i18next": "25.3.6",
"i18next": "25.5.2",
"jimp": "1.6.0",
"keyboardevent-from-electron-accelerator": "2.0.0",
"keyboardevents-areequal": "0.2.2",
@ -115,7 +119,7 @@
"node-id3": "0.2.9",
"peerjs": "1.5.5",
"semver": "7.7.2",
"serve": "14.2.4",
"serve": "14.2.5",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
"socks": "2.8.7",
"solid-element": "1.9.1",
@ -125,35 +129,35 @@
"solid-transition-group": "0.3.0",
"tiny-pinyin": "1.3.2",
"tinyld": "1.3.4",
"virtua": "0.41.5",
"virtua": "0.42.2",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "15.0.1",
"zod": "4.0.17"
"zod": "4.1.5"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "1.0.1",
"@eslint/js": "9.33.0",
"@eslint/js": "9.35.0",
"@malept/flatpak-bundler": "0.4.0",
"@playwright/test": "1.54.2",
"@stylistic/eslint-plugin": "5.2.3",
"@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.3.1",
"@total-typescript/ts-reset": "0.6.1",
"@types/electron-localshortcut": "3.1.3",
"@types/howler": "2.2.12",
"@types/html-to-text": "9.0.4",
"@types/semver": "7.7.0",
"@types/semver": "7.7.1",
"@types/trusted-types": "2.0.7",
"bufferutil": "4.0.9",
"builtin-modules": "5.0.0",
"cross-env": "10.0.0",
"del-cli": "6.0.0",
"discord-api-types": "0.38.20",
"electron": "37.3.0",
"discord-api-types": "0.38.23",
"electron": "38.0.0",
"electron-builder": "26.0.12",
"electron-builder-squirrel-windows": "26.0.12",
"electron-devtools-installer": "4.0.0",
"electron-vite": "4.0.0",
"eslint": "9.33.0",
"eslint": "9.35.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-exports": "1.0.0-beta.5",
"eslint-import-resolver-typescript": "4.4.4",
@ -161,14 +165,14 @@
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-solid": "0.14.5",
"glob": "11.0.3",
"node-gyp": "11.3.0",
"playwright": "1.54.2",
"node-gyp": "11.4.2",
"playwright": "1.55.0",
"ts-morph": "26.0.0",
"typescript": "5.9.2",
"typescript-eslint": "8.39.1",
"typescript-eslint": "8.42.0",
"utf-8-validate": "6.0.5",
"vite": "npm:rolldown-vite@7.1.2",
"vite-plugin-inspect": "11.3.2",
"vite": "npm:rolldown-vite@7.1.5",
"vite-plugin-inspect": "11.3.3",
"vite-plugin-resolve": "2.5.2",
"vite-plugin-solid": "2.11.8",
"ws": "8.18.3"

View File

@ -0,0 +1,27 @@
diff --git a/is.d.ts b/is.d.ts
index fb861f7b401914f0f89cb4edf25c51df5cb05812..82144733cd34d88e2deb2e4713b104418e673f2e 100644
--- a/is.d.ts
+++ b/is.d.ts
@@ -5,6 +5,7 @@ declare namespace is {
export function macOS(): boolean;
export function windows(): boolean;
export function linux(): boolean;
+ export function freebsd(): boolean;
export function x86(): boolean;
export function x64(): boolean;
export function production(): boolean;
diff --git a/is.js b/is.js
index a76bb1755a2728bde185b35d847031d3b8ea4ab0..f6b03406c17342f5af078de069e5bbbd2246e152 100644
--- a/is.js
+++ b/is.js
@@ -39,6 +39,10 @@ module.exports = {
linux: function () {
return process.platform === 'linux'
},
+ // Checks if we are under FreeBSD OS
+ freebsd: function () {
+ return process.platform === "freebsd"
+ },
// Checks if we are the processor's arch is x86
x86: function () {
return process.arch === 'ia32'

837
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import { deepmergeCustom } from 'deepmerge-ts';
import defaultConfig from './defaults';
import store, { IStore } from './store';
import store, { type IStore } from './store';
import plugins from './plugins';
import { restart } from '@/providers/app-controls';

View File

@ -1,5 +1,5 @@
declare module 'custom-electron-prompt' {
import { BrowserWindow } from 'electron';
import { type BrowserWindow } from 'electron';
export type SelectOptions = Record<string, string>;

View File

@ -150,6 +150,13 @@
"visual-tweaks": {
"label": "Οπτικές προσαρμογές",
"submenu": {
"custom-window-title": {
"label": "Προσαρμοσμένος τίτλος παραθύρου",
"prompt": {
"label": "Εισαγωγή προσαρμοσμένου τίτλου παραθύρου: (κενό για απενεργοποίηση)",
"placeholder": "Παράδειγμα: YouTube Music"
}
},
"like-buttons": {
"default": "Προεπιλογή",
"force-show": "Επιβολή εμφάνισης",
@ -381,6 +388,11 @@
},
"templates": {
"title": "Ανοίξτε τον επιλογέα λεζάντας"
},
"toast": {
"caption-changed": "Λεζάντα άλλαξε σε {{language}}",
"caption-disabled": "Λεζάντες απενεργοποιήθηκαν",
"no-captions": "Λεζάντες μη διαθέσιμες για αυτό το τραγούδι"
}
},
"compact-sidebar": {
@ -600,7 +612,15 @@
},
"navigation": {
"description": "Βέλη πλοήγησης Επόμενο/Πίσω ενσωματωμένα απευθείας στο περιβάλλον εργασίας, όπως στο αγαπημένο σας πρόγραμμα περιήγησης",
"name": "Πλοήγηση"
"name": "Πλοήγηση",
"templates": {
"back": {
"title": "Μετάβαση στην προηγούμενη σελίδα"
},
"forward": {
"title": "Μετάβαση στην επόμενη σελίδα"
}
}
},
"no-google-login": {
"description": "Αφαίρεση των κουμπιών και των συνδέσμων σύνδεσης Google από το περιβάλλον εργασίας",
@ -692,7 +712,12 @@
}
},
"description": "Επιτρέπει την αλλαγή της ποιότητας βίντεο με ένα κουμπί στην επικάλυψη βίντεο",
"name": "Αλλαγή ποιότητας βίντεο"
"name": "Αλλαγή ποιότητας βίντεο",
"renderer": {
"quality-settings-button": {
"label": "Άνοιγμα ρυθμίσεων ποιότητας αναπαραγωγέα"
}
}
},
"scrobbler": {
"description": "Προσθήκη υποστήριξης scrobbling (κ.λπ. last.fm, Listenbrainz)",
@ -859,7 +884,8 @@
},
"name": "Εναλλαγή βίντεο",
"templates": {
"button-song": "Τραγούδι"
"button-song": "Τραγούδι",
"button-video": "Βίντεο"
}
},
"visualizer": {

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Configure a custom output media device for songs",
"menu": {
"device-selector": "Select Device"
},
"name": "Custom Output Device",
"prompt": {
"device-selector": {
"label": "Choose the output media device to be used",
"title": "Select Output Device"
}
}
},
"disable-autoplay": {
"description": "Makes song start in \"paused\" mode",
"menu": {
@ -444,7 +457,15 @@
"hide-duration-left": "Hide duration left",
"hide-github-button": "Hide GitHub link Button",
"play-on-youtube-music": "Play on YouTube Music",
"set-inactivity-timeout": "Set inactivity timeout"
"set-inactivity-timeout": "Set inactivity timeout",
"set-status-display-type": {
"label": "Status text",
"submenu": {
"youtube-music": "Listening to YouTube Music",
"artist": "Listening to {artist}",
"title": "Listening to {song title}"
}
}
},
"name": "Discord Rich Presence",
"prompt": {

View File

@ -421,6 +421,19 @@
}
}
},
"custom-output-device": {
"description": "Configura un dispositivo de salida de audio personalizado para las canciones",
"menu": {
"device-selector": "Seleccionar un dispositivo"
},
"name": "Dispositivo de audio personalizado",
"prompt": {
"device-selector": {
"label": "Escoge el dispositivo de salida de audio que se va a usar",
"title": "Seleccionar un dispositivo de audio"
}
}
},
"disable-autoplay": {
"description": "Hace que la canción comience en modo \"pausado\"",
"menu": {

View File

@ -2,14 +2,14 @@
"common": {
"console": {
"plugins": {
"execute-failed": "Misslyckades med att köra plugin {{pluginName}}::{{contextName}}",
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} kördes på {{ms}} ms",
"initialize-failed": "Misslyckades med att initialisera pluginen \"{{pluginName}}\"",
"load-all": "Laddar alla pluginer",
"load-failed": "Misslyckades med att ladda pluginen \"{{pluginName}}\"",
"loaded": "Pluginen \"{{pluginName}}\" laddad",
"unload-failed": "Misslyckades med att avlasta pluginen \"{{pluginName}}\"",
"unloaded": "Pluginen \"{{pluginName}}\" avlastad"
"execute-failed": "Misslyckades med att köra tillägget {{pluginName}}::{{contextName}}",
"executed-at-ms": "Tillägget {{pluginName}}::{{contextName}} kördes på {{ms}}ms",
"initialize-failed": "Misslyckades med att initialisera tillägget \"{{pluginName}}\"",
"load-all": "Laddar alla tillägg",
"load-failed": "Misslyckades med att ladda tillägget \"{{pluginName}}\"",
"loaded": "Tillägget \"{{pluginName}}\" laddades in",
"unload-failed": "Kunde inte inaktivera {{pluginName}}-tillägget",
"unloaded": "{{pluginName}}-tillägget inaktiverat"
}
}
},
@ -21,7 +21,7 @@
"main": {
"console": {
"did-finish-load": {
"dev-tools": "Laddning klar. DevTools öppnad"
"dev-tools": "Laddning slutförd. Utvecklarverktyg öppnad"
},
"i18n": {
"loaded": "i18n laddad"
@ -45,16 +45,16 @@
"dialog": {
"hide-menu-enabled": {
"detail": "Menyn är dold, använd 'Alt' för att visa den (eller 'Escape' om du använder inbyggd meny)",
"message": "Dölj Meny är aktiverat",
"title": "Dölj Meny aktiverad"
"message": "Dölj meny är aktiverad",
"title": "Dölj meny aktiverad"
},
"need-to-restart": {
"buttons": {
"later": "Senare",
"restart-now": "Starta om nu"
},
"detail": "\"{{pluginName}}\" pluginen kräver en omstart för att träda i kraft",
"message": "\"{{pluginName}}\" behöver startas om",
"detail": "\"{{pluginName}}\"-tillägget kräver en omstart för att träda i kraft",
"message": "\"{{pluginName}}\"-tillägget behöver startas om",
"title": "Omstart krävs"
},
"unresponsive": {
@ -84,17 +84,17 @@
"label": "Navigering",
"submenu": {
"copy-current-url": "Kopiera nuvarande länk",
"go-back": "Föregående",
"go-forward": "Nästa",
"go-back": "Gå tillbaka",
"go-forward": "Gå framåt",
"quit": "Lämna",
"restart": "Starta om appen"
}
},
"options": {
"label": "Valmöjligheter",
"label": "Alternativ",
"submenu": {
"advanced-options": {
"label": "Avancerade valmöjligheter",
"label": "Avancerade alternativ",
"submenu": {
"auto-reset-app-cache": "Nollställ appcache när appen startar",
"disable-hardware-acceleration": "Stäng av hårdvaruacceleration",
@ -114,60 +114,589 @@
},
"always-on-top": "Alltid överst",
"auto-update": "Uppdatera automatiskt",
"hide-menu": {
"dialog": {
"message": "Menyn kommer att döljas efter omstart, använd [Alt] för att visa menyn (eller [`] vid användning av menyn inuti applikationen)",
"title": "Dölj meny aktiverad"
},
"label": "Dölj meny"
},
"language": {
"dialog": {
"message": "Språket ändras efter omstart",
"title": "Språket har ändrats"
},
"label": "Språk"
"label": "Språk",
"submenu": {
"to-help-translate": "Vill du hjälpa till att översätta? Klicka här"
}
},
"resume-on-start": "Fortsätt spela när appen öppnas"
"resume-on-start": "Fortsätt spela när appen öppnas",
"single-instance-lock": "Lås enskild instans",
"start-at-login": "Starta vid inloggning",
"starting-page": {
"label": "Startsidа",
"unset": "Ej inställt"
},
"tray": {
"label": "Systemfält",
"submenu": {
"disabled": "Inaktiverad",
"enabled-and-hide-app": "Aktiverad och dölj app",
"enabled-and-show-app": "Aktiverad och visa app",
"play-pause-on-click": "Spela/Pausa vid klick"
}
},
"visual-tweaks": {
"label": "Visuella justeringar",
"submenu": {
"custom-window-title": {
"label": "Anpassad titel på fönstret",
"prompt": {
"label": "Ange anpassad fönstertitel: (lämna tomt för att inaktivera)",
"placeholder": "Exempelvis: YouTube Music"
}
},
"like-buttons": {
"default": "Standard",
"force-show": "Tvinga fram visning",
"hide": "Dölj",
"label": "Gilla-knappar"
},
"remove-upgrade-button": "Ta bort knappen för uppgradering",
"theme": {
"dialog": {
"button": {
"cancel": "Avbryt",
"remove": "Ta bort"
},
"remove-theme": "Vill du verkligen radera det anpassade temat?",
"remove-theme-message": "Det här raderar ditt anpassade tema"
},
"label": "Tema",
"submenu": {
"import-css-file": "Importera anpassad CSS-fil",
"no-theme": "Inget tema"
}
}
}
}
}
},
"plugins": {
"enabled": "Aktiverad",
"label": "Tillägg",
"new": "NY"
},
"view": {
"label": "Visa",
"submenu": {
"force-reload": "Tvinga omladdning",
"reload": "Ladda om",
"reset-zoom": "Verklig storlek",
"toggle-fullscreen": "Växla helskärm",
"zoom-in": "Zooma in",
"zoom-out": "Zooma ut"
}
}
},
"tray": {
"next": "Nästa",
"play-pause": "Spela/Pausa",
"previous": "Föregående",
"quit": "Stäng",
"restart": "Starta om appen",
"show": "Visa fönster",
"tooltip": {
"default": "YouTube Music",
"with-song-info": "YouTube Music: {{artist}} {{title}}"
}
}
},
"plugins": {
"ad-speedup": {
"description": "Om en annons spelas, tystas ljudet och uppspelningshastigheten sätts till 16×",
"name": "Snabba upp annonser"
},
"adblocker": {
"description": "Blockerar annonser och spårning automatiskt",
"menu": {
"blocker": "Blockerare"
},
"name": "Annonsblockerare"
},
"album-actions": {
"description": "Lägger till knappar för Undislike, Dislike, Like och Unlike för att använda detta på alla spår i en spellista eller ett album",
"name": "Albumåtgärder"
},
"album-color-theme": {
"description": "Använder ett dynamiskt tema och visuella effekter baserat på albumets färgpalett",
"menu": {
"color-mix-ratio": {
"label": "Färgblandningsförhållande",
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Albumfärgtema"
},
"ambient-mode": {
"description": "Ger en ljuseffekt genom att försiktigt kasta färger från videon på skärmens bakgrund",
"menu": {
"blur-amount": {
"label": "Oskärpa",
"submenu": {
"pixels": "{{blurAmount}} pixlar"
}
},
"buffer": {
"label": "Buffert",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Opacitet",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Kvalitet",
"submenu": {
"pixels": "{{quality}} pixlar"
}
},
"size": {
"label": "Storlek",
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Mjuk övergång",
"submenu": {
"during": "Under {{interpolationTime}} s"
}
},
"use-fullscreen": {
"label": "Använder helskärm"
}
},
"name": "Ambiensläge"
},
"amuse": {
"description": "Lägger till stöd för YouTube Music i Amuse Now Playing-widgeten av 6K Labs",
"name": "Amuse",
"response": {
"query": "Amuse API-servern körs. Använd GET /query för att hämta information om låt."
}
},
"api-server": {
"description": "Lägger till en API-server för att styra spelaren",
"dialog": {
"request": {
"buttons": {
"allow": "Tillåt",
"deny": "Avvisa"
},
"message": "Tillåt {{ID}} ({{origin}}) att få åtkomst till API:et?",
"title": "Förfrågan om API-åtkomst"
}
},
"menu": {
"auth-strategy": {
"label": "Metod för åtkomstkontroll",
"submenu": {
"auth-at-first": {
"label": "Ge åtkomst vid första begäran"
},
"none": {
"label": "Ingen åtkomstkontroll"
}
}
},
"hostname": {
"label": "Värdnamn"
},
"port": {
"label": "Port"
}
},
"name": "API-server [Beta]",
"prompt": {
"hostname": {
"label": "Ange värdnamnet (t.ex. 0.0.0.0) för API-servern:",
"title": "Värdnamn"
},
"port": {
"label": "Ange porten för API-servern:",
"title": "Port"
}
}
},
"audio-compressor": {
"description": "Applicera komprimering på ljudet (sänker volymen på de starkaste delarna av signalen och höjer volymen på de svagaste delarna)",
"name": "Ljudkompressor"
},
"auth-proxy-adapter": {
"description": "Stöd för användning av autentiseringsproxy-tjänster",
"menu": {
"disable": "Inaktivera proxy-adapter",
"enable": "Aktivera proxy-adapter",
"hostname": {
"label": "Värdnamn"
},
"port": {
"label": "Port"
}
},
"name": "Adapter För Autentiseringsproxy",
"prompt": {
"hostname": {
"label": "Ange värdnamn för lokal proxyserver (kräver omstart):",
"title": "Proxy-värdnamn"
},
"port": {
"label": "Ange port för lokal proxyserver (kräver omstart):",
"title": "Port för proxy"
}
}
},
"blur-nav-bar": {
"description": "Gör navigeringsfältet transparent och suddigt",
"name": "Suddigt Navigeringsfält"
},
"bypass-age-restrictions": {
"description": "Hoppa över YouTubes åldersverifiering",
"name": "Hoppa Över Åldersbegränsningar"
},
"captions-selector": {
"description": "Välj textning för YouTube Music-ljudspår",
"menu": {
"autoload": "Välj automatiskt senast använda textning",
"disable-captions": "Ingen textning som standard"
},
"name": "Textväljare",
"prompt": {
"selector": {
"label": "Aktuellt textningsspråk: {{language}}",
"none": "Inget",
"title": "Välj textspråk"
}
},
"templates": {
"title": "Öppna textväljaren"
},
"toast": {
"caption-changed": "Textning ändrad till {{language}}",
"caption-disabled": "Textning inaktiverad",
"no-captions": "Inga undertexter tillgängliga för denna låt"
}
},
"compact-sidebar": {
"description": "Sätt alltid sidomenyn i kompakt läge",
"name": "Kompakt Sidomeny"
},
"crossfade": {
"description": "Mjuk övergång mellan låtar",
"menu": {
"advanced": "Avancerat"
},
"name": "Mjuk Övergång [Beta]",
"prompt": {
"options": {
"multi-input": {
"fade-in-duration": "Fade-in-varaktighet (ms)",
"fade-out-duration": "Fade-out-varaktighet (ms)",
"fade-scaling": {
"label": "Fade-skalning",
"linear": "Linjär",
"logarithmic": "Logaritmisk"
},
"seconds-before-end": "Övergång i sekunder före slutet"
},
"title": "Övergångsinställningar"
}
}
},
"disable-autoplay": {
"description": "Starta låt i \"pausat\" läge",
"menu": {
"apply-once": "Gäller endast vid uppstart"
},
"name": "Inaktivera Automatisk Uppspelning"
},
"discord": {
"backend": {
"already-connected": "Försökte ansluta med aktiv anslutning",
"connected": "Ansluten till Discord",
"disconnected": "Frånkopplad från Discord"
},
"description": "Visa dina vänner vad du lyssnar på med Aktivitetsdelning",
"menu": {
"auto-reconnect": "Automatisk återanslutning",
"clear-activity": "Rensa aktivitet",
"clear-activity-after-timeout": "Rensa aktivitet efter tidsgräns",
"connected": "Ansluten",
"disconnected": "Frånkopplad",
"hide-duration-left": "Dölj återstående tid",
"hide-github-button": "Dölj knapp för GitHub-länk",
"play-on-youtube-music": "Spela på YouTube Music",
"set-inactivity-timeout": "Ställ in inaktivitetstid"
},
"name": "Discord Aktivitetsdelning",
"prompt": {
"set-inactivity-timeout": {
"label": "Ange inaktivitetstid i sekunder:",
"title": "Ställ in inaktivitetstid"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Hoppsan! Nedladdningen misslyckades…",
"title": "Fel vid nedladdning!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{playlistSize}} låtar)",
"message": "Laddar ner {{playlistTitle}}-spellistan",
"title": "Nedladdning påbörjad"
}
},
"feedback": {
"conversion-progress": "Konvertering: {{percent}}%",
"converting": "Konverterar…",
"done": "Klart: {{filePath}}",
"download-info": "Laddar ner {{artist}} - {{title}} [{{videoId}}",
"download-progress": "Nedladdning: {{percent}}%",
"downloading": "Laddar ner…",
"downloading-counter": "Laddar ner {{current}}/{{total}}…",
"downloading-playlist": "Laddar ner {{playlistTitle}}-spellistan — {{playlistSize}} spår ({{playlistId}})",
"error-while-downloading": "Fel vid nedladdning \"{{author}} - {{title}}\": {{error}}",
"folder-already-exists": "Mappen {{playlistFolder}} finns redan",
"getting-playlist-info": "Hämtar information om spellista…",
"loading": "Laddar…",
"playlist-has-only-one-song": "Spellistan innehåller bara ett objekt. Laddar ner direkt.",
"playlist-id-not-found": "Hittade inget ID för spellista",
"playlist-is-empty": "Spellistan är tom",
"playlist-is-mix-or-private": "Fel vid hämtning av spellisteinformation. Se till att den inte är privat eller en 'Mixed for you'-spellista\n\n{{error}}",
"preparing-file": "Förbereder fil…",
"saving": "Sparar…",
"trying-to-get-playlist-id": "Försöker hämta spelliste-ID: {{playlistId}}",
"video-id-not-found": "Videon hittades inte",
"writing-id3": "Skriver ID3-taggar…"
}
},
"description": "Laddar ner MP3 / originalljud direkt från gränssnittet",
"menu": {
"choose-download-folder": "Välj nedladdningsmapp",
"download-finish-settings": {
"label": "Ladda ner när klart",
"prompt": {
"last-percent": "Efter x procent",
"last-seconds": "Senaste x sekunderna",
"title": "Ställ in när nedladdning ska ske"
},
"submenu": {
"advanced": "Avancerat",
"enabled": "Aktiverad",
"mode": "Tidsläge",
"percent": "Procent",
"seconds": "Sekunder"
}
},
"download-playlist": "Ladda ner spellista",
"presets": "Förinställningar",
"skip-existing": "Hoppa över befintliga filer"
},
"name": "Nedladdare",
"renderer": {
"can-not-update-progress": "Kan inte uppdatera förlopp"
},
"templates": {
"button": "Ladda ner"
}
},
"equalizer": {
"description": "Lägger till en equalizer i spelaren",
"menu": {
"presets": {
"label": "Förinställningar",
"list": {
"bass-booster": "Basförstärkning"
}
}
},
"name": "Equalizer"
},
"exponential-volume": {
"description": "Gör volymreglaget exponentiellt så att det blir lättare att välja lägre volymer.",
"name": "Exponentiell Volym"
},
"in-app-menu": {
"description": "Ger menyrader ett snyggt, mörkt, eller albumfärgat utseende",
"menu": {
"hide-dom-window-controls": "Dölj DOM-fönsterkontroller"
},
"name": "Meny I Appen"
},
"lumiastream": {
"description": "Lägger till stöd för Lumia Stream",
"name": "Lumia Stream [Beta]"
},
"lyrics-genius": {
"description": "Lägger till stöd för texter till de flesta låtar",
"menu": {
"romanized-lyrics": "Romiserade texter"
},
"name": "Texter från Genius",
"renderer": {
"fetched-lyrics": "Hämtade texter från Genius"
}
},
"music-together": {
"description": "Dela en spellista med andra. När värden spelar en låt kommer alla andra höra samma låt",
"dialog": {
"enter-host": "Ange värd-ID"
},
"internal": {
"save": "Spara",
"track-source": "Ljudkälla",
"unknown-user": "Okänd användare"
},
"menu": {
"click-to-copy-id": "Kopiera värd-ID",
"close": "Stäng \"Music Together\"",
"connected-users": "Anslutna användare",
"disconnect": "Koppla från \"Music Together\"",
"empty-user": "Inga anslutna användare",
"host": "Värd för \"Music Together\"",
"join": "Gå med i \"Music Together\"",
"permission": {
"all": "Tillåt gäster att styra spellista och spelare",
"host-only": "Endast värden kan styra spellista och spelare",
"playlist": "Tillåt gäster att styra spellistan"
},
"set-permission": "Ändra behörighet för styrning",
"status": {
"disconnected": "Frånkopplad",
"guest": "Ansluten som gäst",
"host": "Ansluten som värd"
}
},
"name": "Music Together [Beta]",
"toast": {
"add-song-failed": "Misslyckades med att lägga till låt",
"closed": "\"Music Together\" stängdes",
"disconnected": "\"Music Together\" frånkopplad",
"host-failed": "Misslyckades med att vara värd för \"Music Together\"",
"id-copied": "Värd-ID kopierat till urklipp",
"id-copy-failed": "Misslyckades med att kopiera värd-ID till urklipp",
"join-failed": "Misslyckades med att gå med i \"Music Together\"",
"joined": "Gick med i \"Music Together\"",
"permission-changed": "Behörighet för \"Music Together\" ändrad till \"{{permission}}\"",
"remove-song-failed": "Misslyckades med att radera låt",
"user-connected": "{{name}} gick med i \"Music Together\"",
"user-disconnected": "{{name}} lämnade \"Music Together\""
}
},
"navigation": {
"name": "Navigering"
"description": "Direkt integrering av Nästa-/Tillbaka-navigeringspilar i gränssnittet, som i din favoritwebbläsare",
"name": "Navigering",
"templates": {
"back": {
"title": "Gå till föregående sida"
},
"forward": {
"title": "Gå till nästa sida"
}
}
},
"no-google-login": {
"name": "Inget Google Login"
"description": "Ta bort Google-inloggningsknappar och länkar från gränssnittet",
"name": "Ingen Google-inloggning"
},
"notifications": {
"description": "Visa en notis när en låt börjar spelas (interaktiva notiser finns på Windows)",
"menu": {
"interactive": "Interaktiva notiser",
"interactive-settings": {
"label": "Interaktiva inställningar",
"submenu": {
"hide-button-text": "Dölj knapptext",
"refresh-on-play-pause": "Uppdatera vid Play/Pause",
"tray-controls": "Öppna/stäng vid klick i systemfältet"
}
},
"priority": "Notisprioritet",
"toast-style": "Stil för \"toast\"-notiser",
"unpause-notification": "Visa notis när uppspelning återupptas"
},
"name": "Notiser"
},
"performance-improvement": {
"description": "Förbättra prestanda genom att aktivera experimentella skript",
"name": "Prestandaförbättring [Beta]"
},
"picture-in-picture": {
"description": "Tillåter appen att växla till bild-i-bild-läge",
"menu": {
"always-on-top": "Alltid överst",
"hotkey": {
"label": "Snabbkommando",
"prompt": {
"keybind-options": {
"hotkey": "Snabbkommando"
},
"label": "Välj ett snabbkommando för att växla bild-i-bild-läge",
"title": "Bild-I-Bild genväg"
}
}
},
"save-window-position": "Spara fönsterposition",
"save-window-size": "Spara fönsterstorlek",
"use-native-pip": "Använd webbläsarens inbyggda bild-i-bild"
},
"name": "Bild-I-Bild",
"name": "Bild-i-bild",
"templates": {
"button": "Bild-i-bild"
}
},
"playback-speed": {
"description": "Lägger till ett reglage för att ändra uppspelningshastighet",
"name": "Uppspelningshastighet",
"templates": {
"button": "Hasighet"
"button": "Hastighet"
}
},
"precise-volume": {
"description": "Styr ljudstyrkan exakt med mushjul/snabbtangenter, med anpassat skärmlager och justerbara volymsteg",
"menu": {
"arrows-shortcuts": "Kontroller för lokala piltangenter",
"custom-volume-steps": "Ställ in egna volymsteg",
"global-shortcuts": "Globala snabbkommandon"
},
"name": "Noggrann Volymkontroll",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "Minska Volym",
"increase": "Öka Volym"
}
"decrease": "Sänk volymen",
"increase": "Öka volymen"
},
"label": "Välj globala kortkommandon för volym:",
"title": "Globala kortkommandon för volym"
},
"volume-steps": {
"label": "Välj volymsteg för ökning/minskning",
"title": "Volymsteg"
}
}
@ -176,54 +705,195 @@
"backend": {
"dialog": {
"quality-changer": {
"detail": "Nuvarande kvalité: {{quality}}",
"message": "Välj Video Kvalité:",
"title": "Välj Video Kvalité"
"detail": "Nuvarande kvalitet: {{quality}}",
"message": "Välj videokvalitet:",
"title": "Välj videokvalitet"
}
}
},
"description": "Tillåter att ändra videokvalitet med en knapp i videons overlay",
"name": "Videokvalitetsväxlare",
"renderer": {
"quality-settings-button": {
"label": "Öppna kvalitetsväxlare för spelaren"
}
}
},
"scrobbler": {
"description": "Lägg till scrobbling-stöd (t.ex. last.fm, Listenbrainz)",
"dialog": {
"lastfm": {
"auth-failed": {
"message": "Misslyckades att autentisera med Last.fm\nDölj popup-fönstret till nästa omstart.",
"title": "Autentisering misslyckades"
}
}
},
"menu": {
"lastfm": {
"api-settings": "Last.fm API-inställningar"
},
"listenbrainz": {
"token": "Ange ListenBrainz användartoken"
},
"scrobble-alternative-title": "Använd alternativa titlar",
"scrobble-other-media": "Scrobbla annan media"
},
"name": "Scrobbler",
"prompt": {
"lastfm": {
"api-key": "Last.fm API nyckel"
"api-key": "Last.fm API nyckel",
"api-secret": "Last.fm API-hemlighet"
},
"listenbrainz": {
"token": {
"label": "Ange din ListenBrainz användartoken:",
"title": "ListenBrainz token"
}
}
}
},
"shortcuts": {
"description": "Tillåter inställning av globala kortkommandon för uppspelning (spela/pausa/nästa/föregående) och inaktiverar medie-OSD genom att åsidosätta medietangenter. Aktiverar Ctrl/CMD + F för sökning. Aktiverar Linux MPRIS-stöd för medietangenter och anpassade kortkommandon för avancerade användare",
"menu": {
"override-media-keys": "Åsidosätt medietangenter",
"set-keybinds": "Ställ in globala kontroller för låtar"
},
"name": "Genvägar (& MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "Nästa",
"play-pause": "Spela / Pausa",
"previous": "Föregående"
}
},
"label": "Välj globala kortkommandon för kontroll av låtar:",
"title": "Globala kortkommandon"
}
}
},
"skip-disliked-songs": {
"description": "Hoppar över låtar du inte gillar",
"name": "Hoppa Över Låtar Du Inte Gillar"
},
"skip-silences": {
"description": "Hoppa automatiskt över tysta partier i låtar",
"name": "Hoppa Över Tysta Partier"
},
"sponsorblock": {
"description": "Hoppar automatiskt över icke-musikdelar som intro/outro eller delar av musikvideor där ingen musik spelas",
"name": "Blockera Sponsorer"
},
"synced-lyrics": {
"description": "Visar synkroniserade låttexter med hjälp av tjänster som LRClib.",
"errors": {
"fetch": "⚠️ Ett fel uppstod när texterna skulle hämtas.\n\tFörsök igen senare.",
"not-found": "⚠️ Inga texter hittades för denna låt."
},
"menu": {
"default-text-string": {
"label": "Standardtecken mellan låttexter",
"tooltip": "Välj standardtecken att använda för mellanrummet mellan låttexter"
},
"line-effect": {
"label": "Linjeeffekt",
"submenu": {
"fancy": {
"label": "Stiligt",
"tooltip": "Använd stora, app-liknande effekter på den aktuella raden"
},
"focus": {
"label": "Fokus",
"tooltip": "Gör endast den aktuella raden vit"
},
"offset": {
"label": "Förskjutning",
"tooltip": "Förskjut den aktuella raden åt höger"
},
"scale": {
"label": "Skala",
"tooltip": "Skala den aktuella raden"
}
},
"tooltip": "Välj effekt att applicera på den aktuella raden"
},
"precise-timing": {
"label": "Gör låttexterna perfekt synkroniserade",
"tooltip": "Beräkna till millisekunden när nästa rad ska visas (kan ha en liten inverkan på prestanda)"
},
"romanization": {
"label": "Romanisera låttexter",
"tooltip": "Om låttexterna är på ett annat språk, försök visa en latinsk version."
},
"show-lyrics-even-if-inexact": {
"label": "Visa låttexter även om de inte är exakta",
"tooltip": "Om låten inte hittas försöker tillägget igen med en annan sökförfrågan.\nResultatet från det andra försöket kanske inte är exakt."
},
"show-time-codes": {
"label": "Visa tidskoder",
"tooltip": "Visa tidskoderna bredvid låttexterna"
}
},
"name": "Synkroniserade Låttexter",
"refetch-btn": {
"fetching": "Hämtar...",
"normal": "Hämta låttexter igen"
},
"warnings": {
"duration-mismatch": "⚠️ - Texterna kan vara osynkroniserade på grund av en skillnad i spårlängd.",
"inexact": "⚠️ - Låttexterna för den här låten kanske inte är exakta",
"instrumental": "⚠️ - Det här är en instrumentallåt"
}
},
"taskbar-mediacontrol": {
"description": "Kontrollera uppspelning från aktivitetsfältet i Windows",
"name": "Mediakontroll i aktivitetsfältet"
},
"touchbar": {
"description": "Lägger till en TouchBar-widget för macOS-användare",
"name": "TouchBar"
},
"tuna-obs": {
"description": "Integration med OBS-pluginprogrammet Tuna",
"name": "Tuna OBS"
},
"unobtrusive-player": {
"description": "Undviker att spelaren visas när musik spelas",
"name": "Diskret Spelare"
},
"video-toggle": {
"description": "Lägger till en knapp för att växla mellan video/musik-läge. Kan också valfritt ta bort hela videofliken",
"menu": {
"align": {
"label": "Justering",
"submenu": {
"left": "Vänster",
"middle": "Mitten",
"right": "Höger"
}
},
"force-hide": "Tvinga borttagning av videoflik",
"mode": {
"label": "Läge",
"submenu": {
"disabled": "Inaktiverad"
"custom": "Anpassad växling",
"disabled": "Inaktiverad",
"native": "Inbyggd växling"
}
}
},
"name": "Video PÅ/AV",
"templates": {
"button-song": "Låt"
"button-song": "Låt",
"button-video": "Video"
}
},
"visualizer": {
"description": "Lägger till en visualisering i spelaren",
"menu": {
"visualizer-type": "Visualiseringstyp"
},
"name": "Visualiserare"
}
}
}

View File

@ -444,7 +444,15 @@
"hide-duration-left": "ซ่อนระยะเวลาที่เหลือ",
"hide-github-button": "ซ่อนปุ่มลิงก์ GitHub",
"play-on-youtube-music": "เล่นบน YouTube Music",
"set-inactivity-timeout": "ตั้งระยะเวลาไม่มีกิจกรรม"
"set-inactivity-timeout": "ตั้งระยะเวลาไม่มีกิจกรรม",
"set-status-display-type": {
"label": "ข้อความสถานะ",
"submenu": {
"artist": "กำลังฟัง {ชื่อนักร้อง}",
"title": "กำลังฟัง {ชื่อเพลง}",
"youtube-music": "กำลังฟัง YouTube Music"
}
}
},
"name": "แสดงกิจกรรมบนดิสคอร์ด",
"prompt": {

View File

@ -15,7 +15,7 @@ import {
type BrowserWindowConstructorOptions,
} from 'electron';
import enhanceWebRequest, {
BetterSession,
type BetterSession,
} from '@jellybrick/electron-better-web-request';
import is from 'electron-is';
import unhandled from 'electron-unhandled';
@ -59,14 +59,7 @@ import ErrorHtmlAsset from '@assets/error.html?asset';
import { defaultAuthProxyConfig } from '@/plugins/auth-proxy-adapter/config';
import type { PluginConfig } from '@/types/plugins';
if (!is.macOS()) {
delete (await allPlugins())['touchbar'];
}
if (!is.windows()) {
delete (await allPlugins())['taskbar-mediacontrol'];
}
import { type PluginConfig } from '@/types/plugins';
// Catch errors and log them
unhandled({
@ -345,8 +338,8 @@ async function createMainWindow() {
titleBarStyle: useInlineMenu
? 'hidden'
: is.macOS()
? 'hiddenInset'
: 'default',
? 'hiddenInset'
: 'default',
autoHideMenuBar: config.get('options.hideMenu'),
};
@ -360,6 +353,8 @@ async function createMainWindow() {
icon,
width: windowSize.width,
height: windowSize.height,
minWidth: 325,
minHeight: 425,
backgroundColor: '#000',
show: false,
webPreferences: {
@ -534,8 +529,8 @@ app.once('browser-window-created', (_event, win) => {
const updatedUserAgent = is.macOS()
? userAgents.mac
: is.windows()
? userAgents.windows
: userAgents.linux;
? userAgents.windows
: userAgents.linux;
win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent;
@ -956,18 +951,15 @@ function removeContentSecurityPolicy(
betterSession.webRequest.setResolver(
'onHeadersReceived',
async (listeners) => {
return listeners.reduce(
async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
return listeners.reduce(async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
const result = await listener.apply();
return { ...accumulator, ...result };
},
Promise.resolve({ cancel: false }),
);
const result = await listener.apply();
return { ...accumulator, ...result };
}, Promise.resolve({ cancel: false }));
},
);
}

View File

@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain } from 'electron';
import { type BrowserWindow, ipcMain } from 'electron';
import { deepmerge } from 'deepmerge-ts';
import { allPlugins, mainPlugins } from 'virtual:plugins';

View File

@ -1,11 +1,11 @@
import is from 'electron-is';
import {
app,
BrowserWindow,
type BrowserWindow,
clipboard,
dialog,
Menu,
MenuItem,
type MenuItem,
shell,
} from 'electron';
import prompt from 'custom-electron-prompt';

View File

@ -81,26 +81,26 @@ export default createPlugin<
<>
<Show when={showUnDislike()}>
<UnDislikeButton
onClick={this.loadFullList}
maskSize={unDislikeMaskSize()}
onClick={this.loadFullList}
/>
</Show>
<Show when={showDislike()}>
<DislikeButton
onClick={this.loadFullList}
maskSize={dislikeMaskSize()}
onClick={this.loadFullList}
/>
</Show>
<Show when={showLike()}>
<LikeButton
onClick={this.loadFullList}
maskSize={likeMaskSize()}
onClick={this.loadFullList}
/>
</Show>
<Show when={showUnLike()}>
<UnLikeButton
onClick={this.loadFullList}
maskSize={unLikeMaskSize()}
onClick={this.loadFullList}
/>
</Show>
</>

View File

@ -6,22 +6,23 @@ export interface DislikeButtonProps {
export const DislikeButton = (props: DislikeButtonProps) => (
<div class="style-scope">
<button
id="alldislike"
data-type="dislike"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Dislike all"
aria-pressed="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
data-filled="false"
data-type="dislike"
id="alldislike"
onClick={(e) => props.onClick?.(e)}
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
@ -32,24 +33,23 @@ export const DislikeButton = (props: DislikeButtonProps) => (
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div style={{ 'width': '24px', 'height': '24px' }}>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
/>
</g>
</svg>
@ -62,20 +62,20 @@ export const DislikeButton = (props: DislikeButtonProps) => (
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
/>
</g>
</svg>
@ -87,8 +87,8 @@ export const DislikeButton = (props: DislikeButtonProps) => (
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
>
<div class="yt-spec-touch-feedback-shape__stroke" />
<div class="yt-spec-touch-feedback-shape__fill" />

View File

@ -6,22 +6,23 @@ export interface LikeButtonProps {
export const LikeButton = (props: LikeButtonProps) => (
<div class="style-scope">
<button
id="alllike"
data-type="like"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Like all"
aria-pressed="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
data-filled="false"
data-type="like"
id="alllike"
onClick={(e) => props.onClick?.(e)}
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
@ -32,24 +33,23 @@ export const LikeButton = (props: LikeButtonProps) => (
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div style={{ 'width': '24px', 'height': '24px' }}>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
/>
</g>
</svg>
@ -57,20 +57,20 @@ export const LikeButton = (props: LikeButtonProps) => (
</div>
<div style={{ 'width': '24px', 'height': '24px' }}>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
/>
</g>
</svg>
@ -78,8 +78,8 @@ export const LikeButton = (props: LikeButtonProps) => (
</div>
<yt-touch-feedback-shape style={{ 'border-radius': 'inherit' }}>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
>
<div class="yt-spec-touch-feedback-shape__stroke" />
<div class="yt-spec-touch-feedback-shape__fill" />

View File

@ -6,22 +6,23 @@ export interface UnDislikeButtonProps {
export const UnDislikeButton = (props: UnDislikeButtonProps) => (
<div class="style-scope">
<button
id="allundislike"
data-type="dislike"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Undislike all"
aria-pressed="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
data-filled="true"
data-type="dislike"
id="allundislike"
onClick={(e) => props.onClick?.(e)}
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
color: 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
@ -32,7 +33,6 @@ export const UnDislikeButton = (props: UnDislikeButtonProps) => (
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div
style={{
@ -41,20 +41,20 @@ export const UnDislikeButton = (props: UnDislikeButtonProps) => (
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
/>
</g>
</svg>
@ -67,20 +67,20 @@ export const UnDislikeButton = (props: UnDislikeButtonProps) => (
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
/>
</g>
</svg>
@ -92,8 +92,8 @@ export const UnDislikeButton = (props: UnDislikeButtonProps) => (
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
>
<div class="yt-spec-touch-feedback-shape__stroke" />
<div class="yt-spec-touch-feedback-shape__fill" />

View File

@ -6,22 +6,23 @@ export interface UnLikeButtonProps {
export const UnLikeButton = (props: UnLikeButtonProps) => (
<div class="style-scope">
<button
id="allunlike"
data-type="like"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Unlike all"
aria-pressed="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
data-filled="true"
data-type="like"
id="allunlike"
onClick={(e) => props.onClick?.(e)}
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
aria-hidden="true"
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
@ -32,7 +33,6 @@ export const UnLikeButton = (props: UnLikeButtonProps) => (
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div
style={{
@ -41,20 +41,20 @@ export const UnLikeButton = (props: UnLikeButtonProps) => (
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
/>
</g>
</svg>
@ -67,20 +67,20 @@ export const UnLikeButton = (props: UnLikeButtonProps) => (
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
/>
</g>
</svg>
@ -92,8 +92,8 @@ export const UnLikeButton = (props: UnLikeButtonProps) => (
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
>
<div class="yt-spec-touch-feedback-shape__stroke" />
<div class="yt-spec-touch-feedback-shape__fill" />

View File

@ -1,5 +1,5 @@
import { FastAverageColor } from 'fast-average-color';
import Color, { ColorInstance } from 'color';
import Color, { type ColorInstance } from 'color';
import style from './style.css?inline';

View File

@ -3,7 +3,7 @@ import style from './style.css?inline';
import { t } from '@/i18n';
import { createPlugin } from '@/utils';
import { menu } from './menu';
import { AmbientModePluginConfig } from './types';
import { type AmbientModePluginConfig } from './types';
import { waitForElement } from '@/utils/wait-for-element';
const defaultConfig: AmbientModePluginConfig = {

View File

@ -1,8 +1,8 @@
import { MenuItemConstructorOptions } from 'electron';
import { type MenuItemConstructorOptions } from 'electron';
import { t } from '@/i18n';
import { MenuContext } from '@/types/contexts';
import { AmbientModePluginConfig } from './types';
import { type MenuContext } from '@/types/contexts';
import { type AmbientModePluginConfig } from './types';
export interface menuParameters {
getConfig: () => AmbientModePluginConfig | Promise<AmbientModePluginConfig>;

View File

@ -4,7 +4,7 @@ import { type Context, Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import registerCallback, { type SongInfo } from '@/providers/song-info';
import { registerCallback, type SongInfo } from '@/providers/song-info';
import { createBackend } from '@/utils';
import type { AmuseSongInfo } from './types';

View File

@ -0,0 +1 @@
export const API_VERSION = 'v1';

View File

@ -3,17 +3,22 @@ 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 { createNodeWebSocket } from '@hono/node-ws';
import registerCallback from '@/providers/song-info';
import { registerCallback } from '@/providers/song-info';
import { createBackend } from '@/utils';
import { JWTPayloadSchema } from './scheme';
import { registerAuth, registerControl } from './routes';
import { registerAuth, registerControl, registerWebsocket } from './routes';
import { type APIServerConfig, AuthStrategy } from '../config';
import type { BackendType } from './types';
import type { RepeatMode } from '@/types/datahost-get-state';
import type {
LikeType,
RepeatMode,
VolumeState,
} from '@/types/datahost-get-state';
export const backend = createBackend<BackendType, APIServerConfig>({
async start(ctx) {
@ -25,8 +30,10 @@ export const backend = createBackend<BackendType, APIServerConfig>({
});
ctx.ipc.on('ytmd:player-api-loaded', () => {
ctx.ipc.send('ytmd:setup-seeked-listener');
ctx.ipc.send('ytmd:setup-time-changed-listener');
ctx.ipc.send('ytmd:setup-repeat-changed-listener');
ctx.ipc.send('ytmd:setup-like-changed-listener');
ctx.ipc.send('ytmd:setup-volume-changed-listener');
});
@ -37,7 +44,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
ctx.ipc.on(
'ytmd:volume-changed',
(newVolume: number) => (this.volume = newVolume),
(newVolumeState: VolumeState) => (this.volumeState = newVolumeState),
);
this.run(config.hostname, config.port);
@ -62,6 +69,9 @@ export const backend = createBackend<BackendType, APIServerConfig>({
// Custom
init(backendCtx) {
this.app = new Hono();
const ws = createNodeWebSocket({
app: this.app,
});
this.app.use('*', cors());
@ -103,9 +113,14 @@ export const backend = createBackend<BackendType, APIServerConfig>({
backendCtx,
() => this.songInfo,
() => this.currentRepeatMode,
() => this.volume,
() =>
backendCtx.window.webContents.executeJavaScript(
'document.querySelector("#like-button-renderer")?.likeStatus',
) as Promise<LikeType>,
() => this.volumeState,
);
registerAuth(this.app, backendCtx);
registerWebsocket(this.app, ws);
// swagger
this.app.openAPIRegistry.registerComponent(
@ -133,6 +148,8 @@ export const backend = createBackend<BackendType, APIServerConfig>({
});
this.app.get('/swagger', swaggerUI({ url: '/doc' }));
this.injectWebSocket = ws.injectWebSocket.bind(this);
},
run(hostname, port) {
if (!this.app) return;
@ -143,6 +160,10 @@ export const backend = createBackend<BackendType, APIServerConfig>({
port,
hostname,
});
if (this.injectWebSocket && this.server) {
this.injectWebSocket(this.server);
}
} catch (err) {
console.error(err);
}

View File

@ -1,8 +1,12 @@
import { createRoute, z } from '@hono/zod-openapi';
import { ipcMain } from 'electron';
import getSongControls from '@/providers/song-controls';
import {
LikeType,
type RepeatMode,
type VolumeState,
} from '@/types/datahost-get-state';
import {
AddSongToQueueSchema,
@ -19,8 +23,8 @@ import {
SwitchRepeatSchema,
type ResponseSongInfo,
} from '../scheme';
import { API_VERSION } from '../api-version';
import type { RepeatMode } from '@/types/datahost-get-state';
import type { SongInfo } from '@/providers/song-info';
import type { BackendContext } from '@/types/contexts';
import type { APIServerConfig } from '../../config';
@ -28,8 +32,6 @@ import type { HonoApp } from '../types';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
import type { Context } from 'hono';
const API_VERSION = 'v1';
const routes = {
previous: createRoute({
method: 'post',
@ -87,6 +89,24 @@ const routes = {
},
},
}),
getLikeState: createRoute({
method: 'get',
path: `/api/${API_VERSION}/like-state`,
summary: 'get like state',
description: 'Get the current like state',
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({
state: z.enum(LikeType).nullable(),
}),
},
},
},
},
}),
like: createRoute({
method: 'post',
path: `/api/${API_VERSION}/like`,
@ -274,6 +294,7 @@ const routes = {
'application/json': {
schema: z.object({
state: z.number(),
isMuted: z.boolean(),
}),
},
},
@ -526,12 +547,15 @@ const routes = {
}),
};
type PromiseOrValue<T> = T | Promise<T>;
export const register = (
app: HonoApp,
{ window }: BackendContext<APIServerConfig>,
songInfoGetter: () => SongInfo | undefined,
repeatModeGetter: () => RepeatMode | undefined,
volumeGetter: () => number | undefined,
songInfoGetter: () => PromiseOrValue<SongInfo | undefined>,
repeatModeGetter: () => PromiseOrValue<RepeatMode | undefined>,
likeTypeGetter: () => PromiseOrValue<LikeType | undefined>,
volumeStateGetter: () => PromiseOrValue<VolumeState | undefined>,
) => {
const controller = getSongControls(window);
@ -565,6 +589,10 @@ export const register = (
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.getLikeState, async (ctx) => {
ctx.status(200);
return ctx.json({ state: (await likeTypeGetter()) ?? null });
});
app.openapi(routes.like, (ctx) => {
controller.like();
@ -624,9 +652,9 @@ export const register = (
return ctx.body(null);
});
app.openapi(routes.repeatMode, (ctx) => {
app.openapi(routes.repeatMode, async (ctx) => {
ctx.status(200);
return ctx.json({ mode: repeatModeGetter() ?? null });
return ctx.json({ mode: (await repeatModeGetter()) ?? null });
});
app.openapi(routes.switchRepeat, (ctx) => {
const { iteration } = ctx.req.valid('json');
@ -642,9 +670,11 @@ export const register = (
ctx.status(204);
return ctx.body(null);
});
app.openapi(routes.getVolumeState, (ctx) => {
app.openapi(routes.getVolumeState, async (ctx) => {
ctx.status(200);
return ctx.json({ state: volumeGetter() ?? 0 });
return ctx.json(
(await volumeStateGetter()) ?? { state: 0, isMuted: false },
);
});
app.openapi(routes.setFullscreen, (ctx) => {
const { state } = ctx.req.valid('json');
@ -678,8 +708,8 @@ export const register = (
return ctx.json({ state: fullscreen });
});
const songInfo = (ctx: Context) => {
const info = songInfoGetter();
const songInfo = async (ctx: Context) => {
const info = await songInfoGetter();
if (!info) {
ctx.status(204);

View File

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

View File

@ -0,0 +1,137 @@
import { ipcMain } from 'electron';
import { createRoute } from '@hono/zod-openapi';
import { type NodeWebSocket } from '@hono/node-ws';
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';
import { API_VERSION } from '../api-version';
import type { WSContext } from 'hono/ws';
import type { Context, Next } from 'hono';
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
import type { HonoApp } from '../types';
enum DataTypes {
PlayerInfo = 'PLAYER_INFO',
VideoChanged = 'VIDEO_CHANGED',
PlayerStateChanged = 'PLAYER_STATE_CHANGED',
PositionChanged = 'POSITION_CHANGED',
VolumeChanged = 'VOLUME_CHANGED',
RepeatChanged = 'REPEAT_CHANGED',
}
type PlayerState = {
song?: SongInfo;
isPlaying: boolean;
muted: boolean;
position: number;
volume: number;
repeat: RepeatMode;
};
export const register = (app: HonoApp, nodeWebSocket: NodeWebSocket) => {
let volumeState: VolumeState | undefined = undefined;
let repeat: RepeatMode = 'NONE';
let lastSongInfo: SongInfo | undefined = undefined;
const sockets = new Set<WSContext<WebSocket>>();
const send = (type: DataTypes, state: Partial<PlayerState>) => {
sockets.forEach((socket) =>
socket.send(JSON.stringify({ type, ...state })),
);
};
const createPlayerState = ({
songInfo,
volumeState,
repeat,
}: {
songInfo?: SongInfo;
volumeState?: VolumeState;
repeat: RepeatMode;
}): PlayerState => ({
song: songInfo,
isPlaying: songInfo ? !songInfo.isPaused : false,
muted: volumeState?.isMuted ?? false,
position: songInfo?.elapsedSeconds ?? 0,
volume: volumeState?.state ?? 100,
repeat,
});
registerCallback((songInfo, event) => {
if (event === SongInfoEvent.VideoSrcChanged) {
send(DataTypes.VideoChanged, { song: songInfo, position: 0 });
}
if (event === SongInfoEvent.PlayOrPaused) {
send(DataTypes.PlayerStateChanged, {
isPlaying: !(songInfo?.isPaused ?? true),
position: songInfo.elapsedSeconds,
});
}
if (event === SongInfoEvent.TimeChanged) {
send(DataTypes.PositionChanged, { position: songInfo.elapsedSeconds });
}
lastSongInfo = { ...songInfo };
});
ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => {
volumeState = newVolumeState;
send(DataTypes.VolumeChanged, {
volume: volumeState.state,
muted: volumeState.isMuted,
});
});
ipcMain.on('ytmd:repeat-changed', (_, mode: RepeatMode) => {
repeat = mode;
send(DataTypes.RepeatChanged, { repeat });
});
ipcMain.on('ytmd:seeked', (_, t: number) => {
send(DataTypes.PositionChanged, { position: t });
});
app.openapi(
createRoute({
method: 'get',
path: `/api/${API_VERSION}/ws`,
summary: 'websocket endpoint',
description: 'WebSocket endpoint for real-time updates',
responses: {
101: {
description: 'Switching Protocols',
},
},
}),
nodeWebSocket.upgradeWebSocket(() => ({
onOpen(_, ws) {
// "Unsafe argument of type `WSContext<WebSocket>` assigned to a parameter of type `WSContext<WebSocket>`. (@typescript-eslint/no-unsafe-argument)" ????? what?
sockets.add(ws as WSContext<WebSocket>);
ws.send(
JSON.stringify({
type: DataTypes.PlayerInfo,
...createPlayerState({
songInfo: lastSongInfo,
volumeState,
repeat,
}),
}),
);
},
onClose(_, ws) {
sockets.delete(ws as WSContext<WebSocket>);
},
})) as (ctx: Context, next: Next) => Promise<Response>,
);
};

View File

@ -1,9 +1,9 @@
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
import { serve } from '@hono/node-server';
import { type OpenAPIHono as Hono } from '@hono/zod-openapi';
import { type serve } from '@hono/node-server';
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
import type { BackendContext } from '@/types/contexts';
import type { SongInfo } from '@/providers/song-info';
import type { RepeatMode } from '@/types/datahost-get-state';
import type { APIServerConfig } from '../config';
export type HonoApp = Hono;
@ -13,7 +13,8 @@ export type BackendType = {
oldConfig?: APIServerConfig;
songInfo?: SongInfo;
currentRepeatMode?: RepeatMode;
volume?: number;
volumeState?: VolumeState;
injectWebSocket?: (server: ReturnType<typeof serve>) => void;
init: (ctx: BackendContext<APIServerConfig>) => void;
run: (hostname: string, port: number) => void;

View File

@ -1,26 +1,133 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import { type YoutubePlayer } from '@/types/youtube-player';
const lazySafeTry = (...fns: (() => void)[]) => {
for (const fn of fns) {
try {
fn();
} catch {}
}
};
const createCompressorNode = (
audioContext: AudioContext,
): DynamicsCompressorNode => {
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
return compressor;
};
class Storage {
lastSource: MediaElementAudioSourceNode | null = null;
lastContext: AudioContext | null = null;
lastCompressor: DynamicsCompressorNode | null = null;
connected: WeakMap<MediaElementAudioSourceNode, DynamicsCompressorNode> =
new WeakMap();
connectToCompressor = (
source: MediaElementAudioSourceNode | null = null,
audioContext: AudioContext | null = null,
compressor: DynamicsCompressorNode | null = null,
): boolean => {
if (!(source && audioContext && compressor)) return false;
const current = this.connected.get(source);
if (current === compressor) return false;
this.lastSource = source;
this.lastContext = audioContext;
this.lastCompressor = compressor;
if (current) {
lazySafeTry(
() => source.disconnect(current),
() => current.disconnect(audioContext.destination),
);
} else {
lazySafeTry(() => source.disconnect(audioContext.destination));
}
try {
source.connect(compressor);
compressor.connect(audioContext.destination);
this.connected.set(source, compressor);
return true;
} catch (error) {
console.error('connectToCompressor failed', error);
return false;
}
};
disconnectCompressor = (): boolean => {
const source = this.lastSource;
const audioContext = this.lastContext;
if (!(source && audioContext)) return false;
const current = this.connected.get(source);
if (!current) return false;
lazySafeTry(
() => source.connect(audioContext.destination),
() => source.disconnect(current),
() => current.disconnect(audioContext.destination),
);
this.connected.delete(source);
return true;
};
}
const storage = new Storage();
const audioCanPlayHandler = ({
detail: { audioSource, audioContext },
}: CustomEvent<Compressor>) => {
storage.connectToCompressor(
audioSource,
audioContext,
createCompressorNode(audioContext),
);
};
const ensureAudioContextLoad = (playerApi: YoutubePlayer) => {
if (playerApi.getPlayerState() !== 1 || storage.lastContext) return;
playerApi.loadVideoById(
playerApi.getPlayerResponse().videoDetails.videoId,
playerApi.getCurrentTime(),
playerApi.getUserPlaybackQualityPreference(),
);
};
export default createPlugin({
name: () => t('plugins.audio-compressor.name'),
description: () => t('plugins.audio-compressor.description'),
renderer() {
document.addEventListener(
'ytmd:audio-can-play',
({ detail: { audioSource, audioContext } }) => {
const compressor = audioContext.createDynamicsCompressor();
renderer: {
onPlayerApiReady(playerApi) {
ensureAudioContextLoad(playerApi);
},
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
start() {
document.addEventListener('ytmd:audio-can-play', audioCanPlayHandler, {
passive: true,
});
storage.connectToCompressor(
storage.lastSource,
storage.lastContext,
storage.lastCompressor,
);
},
audioSource.connect(compressor);
compressor.connect(audioContext.destination);
},
{ once: true, passive: true },
);
stop() {
document.removeEventListener('ytmd:audio-can-play', audioCanPlayHandler);
storage.disconnectCompressor();
},
},
});

View File

@ -1,16 +1,16 @@
import net from 'net';
import { SocksClient, SocksClientOptions } from 'socks';
import { SocksClient, type SocksClientOptions } from 'socks';
import is from 'electron-is';
import { createBackend, LoggerPrefix } from '@/utils';
import { BackendType } from './types';
import { type BackendType } from './types';
import config from '@/config';
import { AuthProxyConfig, defaultAuthProxyConfig } from '../config';
import { type AuthProxyConfig, defaultAuthProxyConfig } from '../config';
import type { BackendContext } from '@/types/contexts';

View File

@ -1,5 +1,4 @@
import net from 'net';
import type net from 'net';
import type { AuthProxyConfig } from '../config';
import type { Server } from 'http';

View File

@ -2,7 +2,10 @@ import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import backend from './back';
import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
import renderer, {
type CaptionsSelectorConfig,
type LanguageOptions,
} from './renderer';
import type { YoutubePlayer } from '@/types/youtube-player';

View File

@ -9,10 +9,10 @@ export const CaptionsSettingButton = (props: CaptionsSettingsButtonProps) => (
aria-label={props.label}
class="player-captions-button style-scope ytmusic-player-bar"
icon={'yt-icons:subtitles'}
on:click={(e) => props.onClick(e)}
role={'button'}
tabindex={0}
title={props.label}
on:click={(e) => props.onClick(e)}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div

View File

@ -1,6 +1,5 @@
import { Innertube } from 'youtubei.js';
import { BrowserWindow } from 'electron';
import prompt from 'custom-electron-prompt';
import { Howl } from 'howler';
@ -12,6 +11,7 @@ import { VolumeFader } from './fader';
import { t } from '@/i18n';
import type { BrowserWindow } from 'electron';
import type { RendererContext } from '@/types/contexts';
export type CrossfadePluginConfig = {

View File

@ -0,0 +1,54 @@
import prompt from 'custom-electron-prompt';
import { t } from '@/i18n';
import promptOptions from '@/providers/prompt-options';
import { createPlugin } from '@/utils';
import { renderer } from './renderer';
export interface CustomOutputPluginConfig {
enabled: boolean;
output: string;
devices: Record<string, string>;
}
export default createPlugin({
name: () => t('plugins.custom-output-device.name'),
description: () => t('plugins.custom-output-device.description'),
restartNeeded: true,
config: {
enabled: false,
output: 'default',
devices: {},
} as CustomOutputPluginConfig,
menu: ({ setConfig, getConfig, window }) => {
const promptDeviceSelector = async () => {
const options = await getConfig();
const response = await prompt(
{
title: t('plugins.custom-output-device.prompt.device-selector.title'),
label: t('plugins.custom-output-device.prompt.device-selector.label'),
value: options.output || 'default',
type: 'select',
selectOptions: options.devices,
width: 500,
...promptOptions(),
},
window,
).catch(console.error);
if (!response) return;
options.output = response;
setConfig(options);
};
return [
{
label: t('plugins.custom-output-device.menu.device-selector'),
click: promptDeviceSelector,
},
];
},
renderer,
});

View File

@ -0,0 +1,76 @@
import { createRenderer } from '@/utils';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { RendererContext } from '@/types/contexts';
import type { CustomOutputPluginConfig } from './index';
const updateDeviceList = async (
context: RendererContext<CustomOutputPluginConfig>,
) => {
const newDevices: Record<string, string> = {};
const devices = await navigator.mediaDevices
.enumerateDevices()
.then((devices) =>
devices.filter((device) => device.kind === 'audiooutput'),
);
for (const device of devices) {
newDevices[device.deviceId] = device.label;
}
const options = await context.getConfig();
options.devices = newDevices;
context.setConfig(options);
};
const updateSinkId = async (
audioContext?: AudioContext & {
setSinkId?: (sinkId: string) => Promise<void>;
},
sinkId?: string,
) => {
if (!audioContext || !sinkId) return;
if (!('setSinkId' in audioContext)) return;
if (typeof audioContext.setSinkId === 'function') {
await audioContext.setSinkId(sinkId);
}
};
export const renderer = createRenderer<
{
options?: CustomOutputPluginConfig;
audioContext?: AudioContext;
audioCanPlayHandler: (event: CustomEvent<Compressor>) => Promise<void>;
},
CustomOutputPluginConfig
>({
async audioCanPlayHandler({ detail: { audioContext } }) {
this.audioContext = audioContext;
await updateSinkId(audioContext, this.options!.output);
},
async onPlayerApiReady(_: YoutubePlayer, context) {
this.options = await context.getConfig();
await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
navigator.mediaDevices.ondevicechange = async () =>
await updateDeviceList(context);
document.addEventListener('ytmd:audio-can-play', this.audioCanPlayHandler, {
once: true,
passive: true,
});
await updateDeviceList(context);
},
stop() {
document.removeEventListener(
'ytmd:audio-can-play',
this.audioCanPlayHandler,
);
navigator.mediaDevices.ondevicechange = null;
},
async onConfigChange(config) {
this.options = config;
await updateSinkId(this.audioContext, config.output);
},
});

View File

@ -23,3 +23,13 @@ export enum TimerKey {
UpdateTimeout = 'updateTimeout', // Timer for throttled activity updates
DiscordConnectRetry = 'discordConnectRetry', // Timer for Discord connection retries
}
/**
* An enum for Discord's activity.status_display_type field, governing which field of the activity should be used after
* "Listening to..." in the user's Discord status.
*/
export const DiscordStatusDisplayType = {
YOUTUBE_MUSIC: 0,
ARTIST: 1,
TITLE: 2,
} as const;

View File

@ -98,8 +98,11 @@ export class DiscordService {
const activityInfo: SetActivity = {
type: ActivityType.Listening,
statusDisplayType: config.statusDisplayType,
details: truncateString(songInfo.title, 128), // Song title
detailsUrl: songInfo.url ?? undefined,
state: truncateString(songInfo.artist, 128), // Artist name
stateUrl: songInfo.artistUrl,
largeImageKey: songInfo.imageSrc ?? undefined,
largeImageText: songInfo.album
? truncateString(songInfo.album, 128)

View File

@ -2,6 +2,7 @@ import { createPlugin } from '@/utils';
import { backend } from './main';
import { onMenu } from './menu';
import { t } from '@/i18n';
import { DiscordStatusDisplayType } from './constants';
export type DiscordPluginConfig = {
enabled: boolean;
@ -33,6 +34,10 @@ export type DiscordPluginConfig = {
* Hide the "duration left" in the rich presence
*/
hideDurationLeft: boolean;
/**
* Controls which field is displayed in the Discord status text
*/
statusDisplayType: (typeof DiscordStatusDisplayType)[keyof typeof DiscordStatusDisplayType];
};
export default createPlugin({
@ -47,6 +52,7 @@ export default createPlugin({
playOnYouTubeMusic: true,
hideGitHubButton: false,
hideDurationLeft: false,
statusDisplayType: DiscordStatusDisplayType.ARTIST,
} as DiscordPluginConfig,
menu: onMenu,
backend,

View File

@ -1,6 +1,6 @@
import { app } from 'electron';
import registerCallback, { SongInfoEvent } from '@/providers/song-info';
import { registerCallback, SongInfoEvent } from '@/providers/song-info';
import { createBackend } from '@/utils';
import { DiscordService } from './discord-service';

View File

@ -8,6 +8,8 @@ import { setMenuOptions } from '@/config/plugins';
import { t } from '@/i18n';
import { DiscordStatusDisplayType } from './constants';
import type { MenuContext } from '@/types/contexts';
import type { DiscordPluginConfig } from './index';
@ -17,6 +19,15 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => {
discordService?.registerRefreshCallback(refreshMenu);
});
const DiscordStatusDisplayTypeLabels = {
[DiscordStatusDisplayType.YOUTUBE_MUSIC]:
'plugins.discord.menu.set-status-display-type.submenu.youtube-music',
[DiscordStatusDisplayType.ARTIST]:
'plugins.discord.menu.set-status-display-type.submenu.artist',
[DiscordStatusDisplayType.TITLE]:
'plugins.discord.menu.set-status-display-type.submenu.title',
};
export const onMenu = async ({
window,
getConfig,
@ -92,6 +103,21 @@ export const onMenu = async ({
label: t('plugins.discord.menu.set-inactivity-timeout'),
click: () => setInactivityTimeout(window, config),
},
{
label: t('plugins.discord.menu.set-status-display-type.label'),
submenu: Object.values(DiscordStatusDisplayType).map(
(statusDisplayType) => ({
label: t(DiscordStatusDisplayTypeLabels[statusDisplayType]),
type: 'radio',
checked: config.statusDisplayType == statusDisplayType,
click() {
setConfig({
statusDisplayType,
});
},
}),
),
},
];
};

View File

@ -1,4 +1,4 @@
import { TimerKey } from './constants';
import type { TimerKey } from './constants';
/**
* Manages NodeJS Timers, ensuring only one timer exists per key.

View File

@ -1,4 +1,4 @@
import { DefaultPresetList, Preset } from './types';
import { DefaultPresetList, type Preset } from './types';
import style from './style.css?inline';

View File

@ -2,12 +2,12 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import { app, type BrowserWindow, dialog, ipcMain } from 'electron';
import { Innertube, UniversalCache, Utils, YTNodes } from 'youtubei.js';
import is from 'electron-is';
import filenamify from 'filenamify';
import { Mutex } from 'async-mutex';
import NodeID3, { TagConstants } from 'node-id3';
import * as NodeID3 from 'node-id3';
import { BG, type BgConfig } from 'bgutils-js';
import { lazy } from 'lazy-var';
@ -17,7 +17,8 @@ import {
sendFeedback as sendFeedback_,
setBadge,
} from './utils';
import registerCallback, {
import {
registerCallback,
cleanupName,
getImage,
MediaType,
@ -590,7 +591,7 @@ async function writeID3(
tags.image = {
mime: 'image/png',
type: {
id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
id: NodeID3.TagConstants.AttachedPicture.PictureType.FRONT_COVER,
},
description: 'thumbnail',
imageBuffer: coverBuffer,

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron';
import { app, type BrowserWindow } from 'electron';
import is from 'electron-is';
export const getFolder = (customFolder?: string) =>

View File

@ -5,8 +5,8 @@ export const DownloadButton = (props: {
<a
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex={-1}
onClick={props.onClick}
tabindex={-1}
>
<div class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer">
<svg

View File

@ -1,8 +1,15 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import { MenuContext } from '@/types/contexts';
import { MenuTemplate } from '@/menu';
import { defaultPresets, presetConfigs, Preset, FilterConfig } from './presets';
import {
defaultPresets,
presetConfigs,
type Preset,
type FilterConfig,
} from './presets';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';
export type EqualizerPluginConfig = {
enabled: boolean;

View File

@ -1,6 +1,8 @@
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import type { YoutubePlayer } from '@/types/youtube-player';
export default createPlugin({
name: () => t('plugins.exponential-volume.name'),
description: () => t('plugins.exponential-volume.description'),
@ -9,7 +11,16 @@ export default createPlugin({
enabled: false,
},
renderer: {
onPlayerApiReady() {
onPlayerApiReady(playerApi) {
const syncVolume = (playerApi: YoutubePlayer) => {
if (playerApi.getPlayerState() === 3) {
setTimeout(() => syncVolume(playerApi), 0);
return;
}
playerApi.setVolume(playerApi.getVolume());
};
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
@ -48,6 +59,7 @@ export default createPlugin({
propertyDescriptor?.set?.call(this, lowVolume);
},
});
syncVolume(playerApi);
},
},
});

View File

@ -3,10 +3,10 @@ import { register } from 'electron-localshortcut';
import {
BrowserWindow,
Menu,
MenuItem,
type MenuItem,
ipcMain,
nativeImage,
WebContents,
type WebContents,
} from 'electron';
import type { BackendContext } from '@/types/contexts';

View File

@ -2,7 +2,7 @@ import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { TitleBar } from './renderer/TitleBar';
import { defaultInAppMenuConfig, InAppMenuConfig } from './constants';
import { defaultInAppMenuConfig, type InAppMenuConfig } from './constants';
import type { RendererContext } from '@/types/contexts';
@ -33,12 +33,12 @@ export const onRendererLoad = async ({
render(
() => (
<TitleBar
ipc={ipc}
isMacOS={isMacOS}
enableController={
isNotWindowsOrMacOS && !config().hideDOMWindowControls
}
initialCollapsed={window.mainConfig.get('options.hideMenu')}
ipc={ipc}
isMacOS={isMacOS}
/>
),
document.body,

View File

@ -1,4 +1,4 @@
import { JSX } from 'solid-js';
import { type JSX } from 'solid-js';
import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators';

View File

@ -1,4 +1,4 @@
import { JSX, splitProps } from 'solid-js';
import { type JSX, splitProps } from 'solid-js';
import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators';

View File

@ -1,4 +1,4 @@
import { createSignal, JSX, Show, splitProps, mergeProps } from 'solid-js';
import { createSignal, type JSX, Show, splitProps, mergeProps } from 'solid-js';
import { Portal } from 'solid-js/web';
import { css } from 'solid-styled-components';
import { Transition } from 'solid-transition-group';
@ -6,7 +6,7 @@ import {
autoUpdate,
flip,
offset,
OffsetOptions,
type OffsetOptions,
size,
} from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui';
@ -149,17 +149,17 @@ export const Panel = (props: PanelProps) => {
<Portal>
<Transition
appear
enterClass={animationStyle().enter}
enterActiveClass={animationStyle().enterActive}
exitToClass={animationStyle().exitTo}
enterClass={animationStyle().enter}
exitActiveClass={animationStyle().exitActive}
exitToClass={animationStyle().exitTo}
>
<Show when={local.open}>
<ul
{...leftProps}
class={panelStyle()}
data-ytmd-sub-panel={true}
ref={setPanel}
class={panelStyle()}
style={{
'--offset-x': `${position.x}px`,
'--offset-y': `${position.y}px`,

View File

@ -1,5 +1,5 @@
import { createSignal, Match, Show, Switch } from 'solid-js';
import { JSX } from 'solid-js/jsx-runtime';
import { type JSX } from 'solid-js/jsx-runtime';
import { css } from 'solid-styled-components';
import { Portal } from 'solid-js/web';
@ -290,80 +290,80 @@ export const PanelItem = (props: PanelItemProps) => {
return (
<li
ref={setAnchor}
class={itemStyle()}
onMouseEnter={handleHover}
onClick={handleClick}
data-selected={open()}
onClick={handleClick}
onMouseEnter={handleHover}
ref={setAnchor}
>
<Switch fallback={<div class={itemIconStyle()} />}>
<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"
stroke="currentColor"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M5 12l5 5l10 -10" />
</svg>
</Match>
<Match when={props.type === 'radio' && props.checked}>
<svg
class={itemIconStyle()}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
style={{ padding: '6px' }}
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<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"
fill="currentColor"
/>
</svg>
</Match>
<Match when={props.type === 'radio' && !props.checked}>
<svg
class={itemIconStyle()}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
style={{ padding: '6px' }}
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<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"
fill="currentColor"
/>
</svg>
</Match>
</Switch>
<span class={itemLabelStyle()}>{props.name}</span>
<Show when={props.chip} fallback={<div />}>
<Show fallback={<div />} when={props.chip}>
<span class={itemChipStyle()}>{props.chip}</span>
</Show>
<Show when={props.type === 'submenu'}>
<svg
class={itemIconStyle()}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<polyline points="9 6 15 12 9 18" />
</svg>
<Panel
ref={setChild}
open={open()}
anchor={anchor()}
placement={'right-start'}
data-level={props.type === 'submenu' && props.level.join('/')}
offset={{ mainAxis: 8 }}
open={open()}
placement={'right-start'}
ref={setChild}
>
{props.type === 'submenu' && props.children}
</Panel>
@ -371,8 +371,8 @@ export const PanelItem = (props: PanelItemProps) => {
<Show when={props.toolTip}>
<Portal>
<div
ref={setToolTip}
class={popupStyle()}
ref={setToolTip}
style={{
'--offset-x': `${position.x}px`,
'--offset-y': `${position.y}px`,
@ -380,10 +380,10 @@ export const PanelItem = (props: PanelItemProps) => {
>
<Transition
appear
enterClass={animationStyle().enter}
enterActiveClass={animationStyle().enterActive}
exitToClass={animationStyle().exitTo}
enterClass={animationStyle().enter}
exitActiveClass={animationStyle().exitActive}
exitToClass={animationStyle().exitTo}
>
<Show when={toolTipOpen()}>
<div class={toolTipStyle()}>{props.toolTip}</div>

View File

@ -1,4 +1,4 @@
import { Menu, MenuItem } from 'electron';
import { type Menu, type MenuItem } from 'electron';
import {
createEffect,
createResource,
@ -120,22 +120,22 @@ const PanelRenderer = (props: PanelRendererProps) => {
<Switch>
<Match when={subItem().type === 'normal'}>
<PanelItem
type={'normal'}
name={subItem().label}
chip={subItem().sublabel}
toolTip={subItem().toolTip}
commandId={subItem().commandId}
name={subItem().label}
onClick={() => props.onClick?.(subItem().commandId)}
toolTip={subItem().toolTip}
type={'normal'}
/>
</Match>
<Match when={subItem().type === 'submenu'}>
<PanelItem
type={'submenu'}
name={subItem().label}
chip={subItem().sublabel}
toolTip={subItem().toolTip}
level={[...(props.level ?? []), subItem().commandId]}
commandId={subItem().commandId}
level={[...(props.level ?? []), subItem().commandId]}
name={subItem().label}
toolTip={subItem().toolTip}
type={'submenu'}
>
<PanelRenderer
items={subItem().submenu?.items ?? []}
@ -146,26 +146,26 @@ const PanelRenderer = (props: PanelRendererProps) => {
</Match>
<Match when={subItem().type === 'checkbox'}>
<PanelItem
type={'checkbox'}
name={subItem().label}
checked={subItem().checked}
chip={subItem().sublabel}
toolTip={subItem().toolTip}
commandId={subItem().commandId}
name={subItem().label}
onChange={() => props.onClick?.(subItem().commandId)}
toolTip={subItem().toolTip}
type={'checkbox'}
/>
</Match>
<Match when={subItem().type === 'radio'}>
<PanelItem
type={'radio'}
name={subItem().label}
checked={subItem().checked}
chip={subItem().sublabel}
toolTip={subItem().toolTip}
commandId={subItem().commandId}
name={subItem().label}
onChange={() =>
props.onClick?.(subItem().commandId, radioGroup())
}
toolTip={subItem().toolTip}
type={'radio'}
/>
</Match>
<Match when={subItem().type === 'separator'}>
@ -325,10 +325,10 @@ export const TitleBar = (props: TitleBarProps) => {
return (
<nav
data-ytmd-main-panel={true}
class={titleStyle()}
data-macos={props.isMacOS}
data-show={mouseY() < 32}
data-ytmd-main-panel={true}
>
<IconButton
onClick={() => setCollapsed(!collapsed())}
@ -336,7 +336,7 @@ export const TitleBar = (props: TitleBarProps) => {
'border-top-left-radius': '4px',
}}
>
<svg width={16} height={16} viewBox={'0 0 24 24'}>
<svg height={16} viewBox={'0 0 24 24'} width={16}>
<path
d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z"
fill="currentColor"
@ -344,26 +344,29 @@ export const TitleBar = (props: TitleBarProps) => {
</svg>
</IconButton>
<TransitionGroup
enterClass={
ignoreTransition()
? animationStyle().fakeTarget
: animationStyle().enter
}
enterActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().enterActive
}
exitToClass={
enterClass={
ignoreTransition()
? animationStyle().fakeTarget
: animationStyle().exitTo
: animationStyle().enter
}
exitActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().exitActive
}
exitToClass={
ignoreTransition()
? animationStyle().fakeTarget
: animationStyle().exitTo
}
onAfterEnter={(element) => {
(element as HTMLElement).style.removeProperty('transition-delay');
}}
onBeforeEnter={(element) => {
if (ignoreTransition()) return;
const index = Number(element.getAttribute('data-index') ?? 0);
@ -373,9 +376,6 @@ export const TitleBar = (props: TitleBarProps) => {
`${index * 0.025}s`,
);
}}
onAfterEnter={(element) => {
(element as HTMLElement).style.removeProperty('transition-delay');
}}
onBeforeExit={(element) => {
if (ignoreTransition()) return;
const index = Number(element.getAttribute('data-index') ?? 0);
@ -405,18 +405,18 @@ export const TitleBar = (props: TitleBarProps) => {
return (
<>
<MenuButton
ref={setAnchor}
text={item().label}
onClick={handleClick}
selected={openTarget() === anchor()}
data-index={index}
data-length={data()?.items.length}
onClick={handleClick}
ref={setAnchor}
selected={openTarget() === anchor()}
text={item().label}
/>
<Panel
open={openTarget() === anchor()}
anchor={anchor()}
placement={'bottom-start'}
offset={{ mainAxis: 8 }}
open={openTarget() === anchor()}
placement={'bottom-start'}
>
<PanelRenderer
items={item().submenu?.items ?? []}
@ -433,9 +433,9 @@ export const TitleBar = (props: TitleBarProps) => {
<div style={{ flex: 1 }} />
<WindowController
isMaximize={isMaximized()}
onToggleMaximize={handleToggleMaximize}
onMinimize={handleMinimize}
onClose={handleClose}
onMinimize={handleMinimize}
onToggleMaximize={handleToggleMaximize}
/>
</Show>
</nav>

View File

@ -32,61 +32,61 @@ export const WindowController = (props: WindowControllerProps) => {
<div class={containerStyle()}>
<IconButton onClick={props.onMinimize}>
<svg
width={16}
height={16}
fill="none"
height={16}
viewBox="0 0 24 24"
width={16}
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"
fill="currentColor"
/>
</svg>
</IconButton>
<IconButton onClick={props.onToggleMaximize}>
<Show
when={props.isMaximize}
fallback={
<svg
width={16}
height={16}
fill="none"
height={16}
viewBox="0 0 24 24"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
fill="currentColor"
/>
</svg>
}
when={props.isMaximize}
>
<svg
width={16}
height={16}
fill="none"
height={16}
viewBox="0 0 24 24"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
fill="currentColor"
/>
</svg>
</Show>
</IconButton>
<IconButton onClick={props.onClose}>
<svg
width={16}
height={16}
fill="none"
height={16}
viewBox="0 0 24 24"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
fill="currentColor"
/>
</svg>
</IconButton>

View File

@ -1,7 +1,7 @@
import { net } from 'electron';
import { createPlugin } from '@/utils';
import registerCallback from '@/providers/song-info';
import { registerCallback } from '@/providers/song-info';
import { t } from '@/i18n';
type LumiaData = {

View File

@ -1,4 +1,9 @@
import { DataConnection, Peer, PeerError, PeerErrorType } from 'peerjs';
import {
type DataConnection,
Peer,
type PeerError,
PeerErrorType,
} from 'peerjs';
import delay from 'delay';
import type { Permission, Profile, VideoData } from './types';

View File

@ -1,10 +1,9 @@
import prompt from 'custom-electron-prompt';
import { DataConnection } from 'peerjs';
import { t } from '@/i18n';
import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options';
import { waitForElement } from '@/utils/wait-for-element';
import {
getDefaultProfile,
@ -21,8 +20,7 @@ import { createSettingPopup } from './ui/setting';
import settingHTML from './templates/setting.html?raw';
import style from './style.css?inline';
import { waitForElement } from '@/utils/wait-for-element';
import type { DataConnection } from 'peerjs';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { RendererContext } from '@/types/contexts';
import type { VideoDataChanged } from '@/types/video-data-changed';

View File

@ -1,4 +1,4 @@
import {
import type {
ItemPlaylistPanelVideoRenderer,
PlaylistPanelVideoWrapperRenderer,
QueueItem,

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, Notification } from 'electron';
import { app, type BrowserWindow, Notification } from 'electron';
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
@ -8,7 +8,8 @@ import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpac
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import getSongControls from '@/providers/song-controls';
import registerCallback, {
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';

View File

@ -5,7 +5,8 @@ import is from 'electron-is';
import { notificationImage } from './utils';
import interactive from './interactive';
import registerCallback, {
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';

View File

@ -1,5 +1,5 @@
import is from 'electron-is';
import { MenuItem } from 'electron';
import { type MenuItem } from 'electron';
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';

View File

@ -1,11 +1,11 @@
import path from 'node:path';
import fs from 'node:fs';
import { app, NativeImage } from 'electron';
import { app, type NativeImage } from 'electron';
import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
import { SongInfo } from '@/providers/song-info';
import { type SongInfo } from '@/providers/song-info';
import type { NotificationsPluginConfig } from './index';

View File

@ -7,19 +7,19 @@ export const PictureInPictureButton = (props: PictureInPictureButtonProps) => (
<a
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex={-1}
onClick={(e) => props.onClick?.(e)}
tabindex={-1}
>
<div class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer">
<svg
class="style-scope yt-icon"
id="Layer_1"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
id="Layer_1"
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
@ -28,10 +28,10 @@ export const PictureInPictureButton = (props: PictureInPictureButtonProps) => (
<g class="style-scope yt-icon" id="XMLID_6_">
<path
class="style-scope yt-icon"
fill="#aaaaaa"
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
c0,25.8,21.4,46.3,46.3,46.3h419.4c25.8,0,46.3-20.5,46.3-46.3V93.1C512,67.2,490.6,46.7,464.8,46.7z M464.8,418.9H46.3V92.2h419.4
v326.8H464.8z"
fill="#aaaaaa"
id="XMLID_11_"
/>
</g>

View File

@ -26,10 +26,10 @@ export const PlaybackSpeedSlider = (props: PlaybackSpeedSliderProps) => (
aria-valuenow={props.speed}
class="volume-slider style-scope ytmusic-player-bar on-hover"
dir="ltr"
on:immediate-value-changed={(e) => props.onImmediateValueChanged?.(e)}
onWheel={(e) => props.onWheel?.(e)}
max="2"
min="0"
on:immediate-value-changed={(e) => props.onImmediateValueChanged?.(e)}
onWheel={(e) => props.onWheel?.(e)}
role="slider"
step="0.125"
style={{ 'display': 'inherit !important' }}

View File

@ -43,8 +43,6 @@ export const onPlayerApiReady = () => {
render(
() => (
<PlaybackSpeedSlider
speed={speed()}
title={t('plugins.playback-speed.templates.button')}
onImmediateValueChanged={(e) => {
let targetSpeed = Number(e.detail.value ?? MIN_PLAYBACK_SPEED);
@ -78,6 +76,8 @@ export const onPlayerApiReady = () => {
updatePlayBackSpeed();
}}
speed={speed()}
title={t('plugins.playback-speed.templates.button')}
/>
),
sliderContainer,

View File

@ -1,5 +1,5 @@
import { globalShortcut, MenuItem } from 'electron';
import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { globalShortcut, type MenuItem } from 'electron';
import prompt, { type KeybindOptions } from 'custom-electron-prompt';
import hudStyle from './volume-hud.css?inline';
import { createPlugin } from '@/utils';

View File

@ -66,7 +66,7 @@ export const onPlayerApiReady = async (
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!await window.mainConfig.plugins.isEnabled('video-toggle')) {
if (!(await window.mainConfig.plugins.isEnabled('video-toggle'))) {
// Video-toggle handles hud positioning on its own
const videoMode = () =>
api.getPlayerResponse().videoDetails?.musicVideoType !==

View File

@ -9,10 +9,10 @@ export const QualitySettingButton = (props: QualitySettingButtonProps) => (
aria-label={props.label}
class="player-quality-button style-scope ytmusic-player"
icon={'yt-icons:settings'}
on:click={(e) => props.onClick(e)}
role={'button'}
tabindex={0}
title={props.label}
on:click={(e) => props.onClick(e)}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div

View File

@ -1,6 +1,7 @@
import { BrowserWindow } from 'electron';
import { type BrowserWindow } from 'electron';
import registerCallback, {
import {
registerCallback,
MediaType,
type SongInfo,
SongInfoEvent,

View File

@ -1,12 +1,12 @@
import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron';
import { type BrowserWindow } from 'electron';
import { t } from '@/i18n';
import promptOptions from '@/providers/prompt-options';
import { ScrobblerPluginConfig } from './index';
import { SetConfType, backend } from './main';
import { type ScrobblerPluginConfig } from './index';
import { type SetConfType, backend } from './main';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';

View File

@ -1,4 +1,4 @@
import { BrowserWindow, globalShortcut } from 'electron';
import { type BrowserWindow, globalShortcut } from 'electron';
import is from 'electron-is';
import { register as registerElectronLocalShortcut } from 'electron-localshortcut';

View File

@ -1,4 +1,4 @@
import prompt, { KeybindOptions } from 'custom-electron-prompt';
import prompt, { type KeybindOptions } from 'custom-electron-prompt';
import promptOptions from '@/providers/prompt-options';

View File

@ -1,7 +1,7 @@
declare module '@jellybrick/mpris-service' {
import { EventEmitter } from 'events';
import { interface as dbusInterface } from '@jellybrick/dbus-next';
import { type interface as dbusInterface } from '@jellybrick/dbus-next';
interface RootInterfaceOptions {
identity?: string;

View File

@ -1,20 +1,21 @@
import { BrowserWindow, ipcMain } from 'electron';
import { type BrowserWindow, ipcMain } from 'electron';
import MprisPlayer, {
LOOP_STATUS_NONE,
LOOP_STATUS_PLAYLIST,
LOOP_STATUS_TRACK,
LoopStatus,
type LoopStatus,
PLAYBACK_STATUS_PAUSED,
PLAYBACK_STATUS_PLAYING,
PLAYBACK_STATUS_STOPPED,
type PlayBackStatus,
type PlayerOptions,
type Position,
Track,
type Track,
} from '@jellybrick/mpris-service';
import registerCallback, {
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';
@ -22,7 +23,7 @@ import getSongControls from '@/providers/song-controls';
import config from '@/config';
import { LoggerPrefix } from '@/utils';
import type { RepeatMode } from '@/types/datahost-get-state';
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
class YTPlayer extends MprisPlayer {
@ -305,8 +306,10 @@ function registerMPRIS(win: BrowserWindow) {
console.trace(error);
});
ipcMain.on('ytmd:volume-changed', (_, newVol) => {
player.volume = Number.parseFloat((newVol / 100).toFixed(2));
ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => {
player.volume = newVolumeState.isMuted
? 0
: Number.parseFloat((newVolumeState.state / 100).toFixed(2));
});
player.on('volume', async (newVolume: number) => {

View File

@ -1,5 +1,5 @@
// Segments are an array [ [start, end], … ]
import { Segment } from './types';
import type { Segment } from './types';
export const sortSegments = (segments: Segment[]) => {
segments.sort((segment1, segment2) =>

View File

@ -2,8 +2,6 @@ import { createStore } from 'solid-js/store';
import { createMemo } from 'solid-js';
import { SongInfo } from '@/providers/song-info';
import { LRCLib } from './LRCLib';
import { LyricsGenius } from './LyricsGenius';
import { MusixMatch } from './MusixMatch';
@ -12,6 +10,7 @@ import { YTMusic } from './YTMusic';
import { getSongInfo } from '@/providers/song-info-front';
import type { LyricProvider, LyricResult } from '../types';
import type { SongInfo } from '@/providers/song-info';
export const providers = {
YTMusic: new YTMusic(),

View File

@ -45,7 +45,6 @@ export const ErrorDisplay = (props: ErrorDisplayProps) => {
</pre>
<yt-button-renderer
onClick={() => retrySearch(lyricsStore.provider, getSongInfo())}
data={{
icon: { iconType: 'REFRESH' },
isDisabled: false,
@ -54,6 +53,7 @@ export const ErrorDisplay = (props: ErrorDisplayProps) => {
simpleText: t('plugins.synced-lyrics.refetch-btn.normal')
},
}}
onClick={() => retrySearch(lyricsStore.provider, getSongInfo())}
style={{
'margin-top': '1em',
'width': '100%'

View File

@ -7,16 +7,16 @@ import {
Match,
onCleanup,
onMount,
Setter,
type Setter,
Switch,
} from 'solid-js';
import {
currentLyrics,
lyricsStore,
ProviderName,
type ProviderName,
providerNames,
ProviderState,
type ProviderState,
setLyricsStore,
} from '../../providers';
@ -132,11 +132,11 @@ export const LyricsPicker = (props: {
>
<svg
class="style-scope yt-icon"
fill="#FFFFFF"
height={'40px'}
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
height={'40px'}
width={'40px'}
fill="#FFFFFF"
>
<g class="style-scope yt-icon">
<path
@ -156,10 +156,10 @@ export const LyricsPicker = (props: {
{(provider) => (
<div
class="lyrics-picker-item"
tabindex="-1"
style={{
transform: `translateX(${providerIdx() * -100 - 5}%)`,
}}
tabindex="-1"
>
<Switch>
<Match
@ -170,16 +170,16 @@ export const LyricsPicker = (props: {
>
<tp-yt-paper-spinner-lite
active
tabindex="-1"
class="loading-indicator style-scope"
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match when={currentLyrics().state === 'error'}>
<yt-icon-button
icon={errorIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match
@ -191,8 +191,8 @@ export const LyricsPicker = (props: {
>
<yt-icon-button
icon={successIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match
@ -204,8 +204,8 @@ export const LyricsPicker = (props: {
>
<yt-icon-button
icon={notFoundIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
</Switch>
@ -252,11 +252,11 @@ export const LyricsPicker = (props: {
>
<svg
class="style-scope yt-icon"
fill="#FFFFFF"
height={'40px'}
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
height={'40px'}
width={'40px'}
fill="#FFFFFF"
>
<g class="style-scope yt-icon">
<path

View File

@ -1,8 +1,8 @@
import { createEffect, createMemo, For, Show, createSignal } from 'solid-js';
import { VirtualizerHandle } from 'virtua/solid';
import { type VirtualizerHandle } from 'virtua/solid';
import { LineLyrics } from '@/plugins/synced-lyrics/types';
import { type LineLyrics } from '@/plugins/synced-lyrics/types';
import { config } from '../renderer';
import { _ytAPI } from '..';
@ -39,7 +39,6 @@ export const SyncedLine = (props: SyncedLineProps) => {
return (
<Show
when={text()}
fallback={
<yt-formatted-string
text={{
@ -47,6 +46,7 @@ export const SyncedLine = (props: SyncedLineProps) => {
}}
/>
}
when={text()}
>
<div
class={`synced-line ${props.status}`}
@ -54,7 +54,7 @@ export const SyncedLine = (props: SyncedLineProps) => {
_ytAPI?.seekTo((props.line.timeInMs + 10) / 1000);
}}
>
<div dir="auto" class="description ytmusic-description-shelf-renderer">
<div class="description ytmusic-description-shelf-renderer" dir="auto">
<yt-formatted-string
text={{
runs: [

View File

@ -301,8 +301,8 @@ export const LyricsRenderer = () => {
return (
<SyncedLine
{...props}
scroller={scroller()!}
index={idx()}
scroller={scroller()!}
status={statuses()[idx() - 1]}
/>
);

View File

@ -1,15 +1,11 @@
import { render } from 'solid-js/web';
import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji';
import Kuroshiro from 'kuroshiro';
import { romanize as esHangulRomanize } from 'es-hangul';
import hanja from 'hanja';
import pinyin from 'tiny-pinyin';
import * as pinyin from 'tiny-pinyin';
import { romanize as romanizeThaiFrag } from '@dehoist/romanize-thai';
import { lazy } from 'lazy-var';
import { detect } from 'tinyld';
import { waitForElement } from '@/utils/wait-for-element';
@ -155,26 +151,9 @@ const hasKorean = (lines: string[]) =>
const hasChinese = (lines: string[]) =>
lines.some((line) => /[\u4E00-\u9FFF]+/.test(line));
export const romanize = async (line: string) => {
const lang = detect(line);
const handlers: Record<string, (line: string) => Promise<string> | string> = {
ja: romanizeJapanese,
ko: romanizeHangul,
zh: romanizeChinese,
};
const NO_OP = (l: string) => l;
const handler = handlers[lang] ?? NO_OP;
line = await handler(line);
if (hasJapanese([line])) line = await romanizeJapanese(line);
if (hasKorean([line])) line = romanizeHangul(line);
if (hasChinese([line])) line = romanizeChinese(line);
return line;
};
// https://en.wikipedia.org/wiki/Thai_(Unicode_block)
const hasThai = (lines: string[]) =>
lines.some((line) => /[\u0E00-\u0E7F]+/.test(line));
export const romanizeJapanese = async (line: string) =>
(await kuroshiro.get()).convert(line, {
@ -190,3 +169,47 @@ export const romanizeChinese = (line: string) => {
pinyin.convertToPinyin(match, ' ', true),
);
};
const thaiSegmenter = Intl.Segmenter.supportedLocalesOf('th').includes('th')
? new Intl.Segmenter('th', { granularity: 'word' })
: null;
export const romanizeThai = (line: string) => {
if (!thaiSegmenter) return romanizeThaiFrag(line);
const segments = Array.from(thaiSegmenter.segment(line));
const latin = segments
.map((segment) =>
segment.isWordLike
? romanizeThaiFrag(segment.segment)
: segment.segment.trim(),
)
.join(' ')
.trim();
return latin;
};
const handlers: Record<string, (line: string) => Promise<string> | string> = {
ja: romanizeJapanese,
ko: romanizeHangul,
zh: romanizeChinese,
th: romanizeThai,
};
export const romanize = async (line: string) => {
const lang = detect(line);
const handler = handlers[lang];
if (handler) {
return handler(line);
}
// fallback
if (hasJapanese([line])) line = await romanizeJapanese(line);
if (hasKorean([line])) line = romanizeHangul(line);
if (hasChinese([line])) line = romanizeChinese(line);
if (hasThai([line])) line = romanizeThai(line);
return line;
};

View File

@ -8,17 +8,20 @@ import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpac
import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import registerCallback, {
import {
registerCallback,
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';
import { mediaIcons } from '@/types/media-icons';
import { type mediaIcons } from '@/types/media-icons';
import { t } from '@/i18n';
import { Platform } from '@/types/plugins';
export default createPlugin({
name: () => t('plugins.taskbar-mediacontrol.name'),
description: () => t('plugins.taskbar-mediacontrol.description'),
restartNeeded: true,
platform: Platform.Windows,
config: {
enabled: false,
},

View File

@ -2,15 +2,17 @@ import { nativeImage, type NativeImage, TouchBar } from 'electron';
import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import registerCallback, { SongInfoEvent } from '@/providers/song-info';
import { registerCallback, SongInfoEvent } from '@/providers/song-info';
import { t } from '@/i18n';
import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
import { Platform } from '@/types/plugins';
export default createPlugin({
name: () => t('plugins.touchbar.name'),
description: () => t('plugins.touchbar.description'),
restartNeeded: true,
platform: Platform.macOS,
config: {
enabled: false,
},

View File

@ -3,7 +3,7 @@ import { net } from 'electron';
import is from 'electron-is';
import { createPlugin } from '@/utils';
import registerCallback from '@/providers/song-info';
import { registerCallback } from '@/providers/song-info';
import { t } from '@/i18n';
interface Data {

View File

@ -7,9 +7,9 @@ import buttonSwitcherStyle from './button-switcher.css?inline';
import { createPlugin } from '@/utils';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '@/plugins/precise-volume/renderer';
import { ThumbnailElement } from '@/types/get-player-response';
import { type ThumbnailElement } from '@/types/get-player-response';
import { t } from '@/i18n';
import { MenuTemplate } from '@/menu';
import { type MenuTemplate } from '@/menu';
import { VideoSwitchButton } from './templates/video-switch-button';
@ -177,12 +177,12 @@ export default createPlugin({
() => (
<Show when={showButton()}>
<VideoSwitchButton
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const target = e.target as HTMLInputElement;
setVideoState(target.checked);
}}
onClick={(e) => e.stopPropagation()}
songButtonText={t('plugins.video-toggle.templates.button-song')}
videoButtonText={t('plugins.video-toggle.templates.button-video')}
/>

View File

@ -14,8 +14,8 @@ export const VideoSwitchButton = (props: VideoSwitchButtonProps) => (
>
<input
checked={true}
id="video-toggle-video-switch-button-checkbox"
class="video-switch-button-checkbox"
id="video-toggle-video-switch-button-checkbox"
type="checkbox"
/>
<label

View File

@ -1,6 +1,6 @@
import emptyStyle from './empty-player.css?inline';
import { createPlugin } from '@/utils';
import { Visualizer } from './visualizers/visualizer';
import { type Visualizer } from './visualizers/visualizer';
import {
ButterchurnVisualizer as butterchurn,
VudioVisualizer as vudio,

View File

@ -1,7 +1,7 @@
import {
contextBridge,
ipcRenderer,
IpcRendererEvent,
type IpcRendererEvent,
webFrame,
} from 'electron';
import is from 'electron-is';

View File

@ -1,6 +1,6 @@
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { app, type BrowserWindow } from 'electron';
import getSongControls from './song-controls';

View File

@ -1,5 +1,7 @@
// This is used for to control the songs
import { BrowserWindow, ipcMain } from 'electron';
import { type BrowserWindow, ipcMain } from 'electron';
import { LikeType } from '@/types/datahost-get-state';
// see protocol-handler.ts
type ArgsType<T> = T | string[] | undefined;
@ -42,8 +44,8 @@ export default (win: BrowserWindow) => {
play: () => win.webContents.send('ytmd:play'),
pause: () => win.webContents.send('ytmd:pause'),
playPause: () => win.webContents.send('ytmd:toggle-play'),
like: () => win.webContents.send('ytmd:update-like', 'LIKE'),
dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'),
like: () => win.webContents.send('ytmd:update-like', LikeType.Like),
dislike: () => win.webContents.send('ytmd:update-like', LikeType.Dislike),
seekTo: (seconds: ArgsType<number>) => {
const secondsNumber = parseNumberFromArgsType(seconds);
if (secondsNumber !== null) {

View File

@ -1,7 +1,8 @@
import { singleton } from './decorators';
import { LikeType, type GetState } from '@/types/datahost-get-state';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { GetState } from '@/types/datahost-get-state';
import type {
AlbumDetails,
PlayerOverlays,
@ -79,12 +80,52 @@ export const setupRepeatChangedListener = singleton(() => {
);
});
const mapLikeStatus = (status: string | null): LikeType =>
Object.values(LikeType).includes(status as LikeType)
? (status as LikeType)
: LikeType.Indifferent;
const LIKE_STATUS_ATTRIBUTE = 'like-status';
export const setupLikeChangedListener = singleton(() => {
const likeDislikeObserver = new MutationObserver((mutations) => {
window.ipcRenderer.send(
'ytmd:like-changed',
mapLikeStatus(
(mutations[0].target as HTMLElement)?.getAttribute?.(
LIKE_STATUS_ATTRIBUTE,
),
),
);
});
const likeButtonRenderer = document.querySelector('#like-button-renderer');
if (likeButtonRenderer) {
likeDislikeObserver.observe(likeButtonRenderer, {
attributes: true,
attributeFilter: [LIKE_STATUS_ATTRIBUTE],
});
// Emit the initial value as well; as it's persistent between launches.
window.ipcRenderer.send(
'ytmd:like-changed',
mapLikeStatus(likeButtonRenderer.getAttribute?.(LIKE_STATUS_ATTRIBUTE)),
);
}
});
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
document.querySelector('video')?.addEventListener('volumechange', () => {
window.ipcRenderer.send('ytmd:volume-changed', api.getVolume());
window.ipcRenderer.send('ytmd:volume-changed', {
state: api.getVolume(),
isMuted: api.isMuted(),
});
});
// Emit the initial value as well; as it's persistent between launches.
window.ipcRenderer.send('ytmd:volume-changed', api.getVolume());
window.ipcRenderer.send('ytmd:volume-changed', {
state: api.getVolume(),
isMuted: api.isMuted(),
});
});
export const setupShuffleChangedListener = singleton(() => {
@ -153,6 +194,10 @@ export default (api: YoutubePlayer) => {
setupTimeChangedListener();
});
window.ipcRenderer.on('ytmd:setup-like-changed-listener', () => {
setupLikeChangedListener();
});
window.ipcRenderer.on('ytmd:setup-repeat-changed-listener', () => {
setupRepeatChangedListener();
});

View File

@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain, nativeImage, net } from 'electron';
import { type BrowserWindow, ipcMain, nativeImage, net } from 'electron';
import { Mutex } from 'async-mutex';
@ -30,6 +30,7 @@ export interface SongInfo {
title: string;
alternativeTitle?: string;
artist: string;
artistUrl?: string;
views: number;
uploadDate?: string;
imageSrc?: string | null;
@ -72,6 +73,7 @@ const handleData = async (
title: '',
alternativeTitle: '',
artist: '',
artistUrl: '',
views: 0,
uploadDate: '',
imageSrc: '',
@ -93,6 +95,9 @@ const handleData = async (
songInfo.url = microformat.urlCanonical?.split('&')[0];
songInfo.playlistId =
new URL(microformat.urlCanonical).searchParams.get('list') ?? '';
if (microformat.pageOwnerDetails?.externalChannelId) {
songInfo.artistUrl = `https://music.youtube.com/channel/${microformat.pageOwnerDetails.externalChannelId}`;
}
// Used for options.resumeOnStart
config.set('url', microformat.urlCanonical);
songInfo.alternativeTitle = microformat.linkAlternates.find(
@ -110,7 +115,7 @@ const handleData = async (
songInfo.elapsedSeconds = videoDetails.elapsedSeconds;
songInfo.isPaused = videoDetails.isPaused;
songInfo.videoId = videoDetails.videoId;
songInfo.album = data?.videoDetails?.album; // Will be undefined if video exist
songInfo.album = videoDetails.album; // Will be undefined if video exist
switch (videoDetails?.musicVideoType) {
case 'MUSIC_VIDEO_TYPE_ATV':
@ -179,7 +184,7 @@ export type SongInfoCallback = (
const callbacks: Set<SongInfoCallback> = new Set();
// This function will allow plugins to register callback that will be triggered when data changes
const registerCallback = (callback: SongInfoCallback) => {
export const registerCallback = (callback: SongInfoCallback) => {
callbacks.add(callback);
};
@ -277,5 +282,4 @@ export function cleanupName(name: string): string {
return name;
}
export default registerCallback;
export const setupSongInfo = registerProvider;

2
src/reset.d.ts vendored
View File

@ -19,6 +19,8 @@ declare global {
'videodatachange': CustomEvent<VideoDataChanged>;
}
declare var electronIs: typeof import('electron-is');
interface Window {
trustedTypes?: typeof trustedTypes;
ipcRenderer: typeof electronIpcRenderer;

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