diff --git a/Dockerfile b/Dockerfile index e99cd55e8..512044c69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ WORKDIR /app # Copy package.json first to cache node_modules COPY package.json pnpm-lock.yaml pnpm-workspace.yaml . +# Match packageManager in package.json (pnpm is not bundled in the Node image) +RUN corepack enable && corepack prepare pnpm@11.5.2 --activate + RUN pnpm install # Copy code and build with cached modules diff --git a/docs/ENV_SETTINGS.md b/docs/ENV_SETTINGS.md index 3417cc76b..ea451a142 100644 --- a/docs/ENV_SETTINGS.md +++ b/docs/ENV_SETTINGS.md @@ -100,6 +100,8 @@ These variables override app settings **on first run** when no persisted setting | `lyrics.fetch` | `true` | `FS_LYRICS_FETCH` | `true` / `false` — Fetch lyrics. | | `lyrics.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. | | `lyrics.delayMs` | `0` | `FS_LYRICS_DELAY_MS` | Sync delay in milliseconds. | +| `lyrics.lineLeadTimeMs` | `800` | `FS_LYRICS_LINE_LEAD_TIME_MS` | Milliseconds before a line becomes active while following. | +| `lyrics.followScrollAlignment` | `0` | `FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT` | Vertical follow position. `0` centers the active line; negative moves it higher, positive moves it lower (`-50` to `50`). | | `lyrics.preferLocalLyrics` | `true` | `FS_LYRICS_PREFER_LOCAL` | `true` / `false` — Prefer local lyric files. | | `lyrics.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `true` / `false`. | | `lyrics.showProvider` | `true` | `FS_LYRICS_SHOW_PROVIDER` | `true` / `false`. | @@ -110,6 +112,17 @@ These variables override app settings **on first run** when no persisted setting --- +## Lyrics display + +Applies to the default lyrics display profile (`lyricsDisplay.default`). + +| Setting path | Default | Env variable | Available values / Description | +|-------------|---------|--------------|--------------------------------| +| `lyricsDisplay.default.paddingLeft` | `0` | `FS_LYRICS_PADDING_LEFT` | Left content padding as a percentage (`0`–`20`). | +| `lyricsDisplay.default.paddingRight` | `0` | `FS_LYRICS_PADDING_RIGHT` | Right content padding as a percentage (`0`–`20`). | + +--- + ## Auto DJ | Setting path | Default | Env variable | Available values / Description | diff --git a/electron-builder-alpha.yml b/electron-builder-alpha.yml index bafcc678d..e792db95e 100644 --- a/electron-builder-alpha.yml +++ b/electron-builder-alpha.yml @@ -71,4 +71,5 @@ publish: provider: s3 bucket: feishin-nightly channel: alpha + region: auto endpoint: https://065f090c64de2dc707dd70ac72db9669.r2.cloudflarestorage.com diff --git a/feishin.desktop.tmpl b/feishin.desktop.tmpl old mode 100644 new mode 100755 index 8f33a7298..c6b35ab85 --- a/feishin.desktop.tmpl +++ b/feishin.desktop.tmpl @@ -1,3 +1,4 @@ +#!/usr/bin/env xdg-open [Desktop Entry] Name=Feishin GenericName=Music player diff --git a/package.json b/package.json index d87e15d80..0fcb605bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feishin", - "version": "1.13.0", + "version": "1.14.0", "description": "A modern self-hosted music player.", "keywords": [ "subsonic", @@ -99,7 +99,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.21", - "dompurify": "^3.4.8", + "dompurify": "^3.4.11", "electron-debug": "^3.2.0", "electron-localshortcut": "^3.2.1", "electron-log": "^5.4.4", @@ -114,6 +114,8 @@ "idb-keyval": "^6.2.5", "immer": "^10.2.0", "is-electron": "^2.2.2", + "kuroshiro": "^1.2.0", + "kuroshiro-analyzer-kuromoji": "^1.1.0", "lodash": "^4.18.1", "md5": "^2.3.0", "motion": "^12.40.0", @@ -162,7 +164,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^41.7.1", - "electron-builder": "^26.15.0", + "electron-builder": "^26.15.5", "electron-devtools-installer": "^4.0.0", "electron-vite": "^4.0.1", "eslint": "^9.39.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea775739c..c0c5d0343 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,8 +94,8 @@ importers: specifier: ^1.11.21 version: 1.11.21 dompurify: - specifier: ^3.4.8 - version: 3.4.10 + specifier: ^3.4.11 + version: 3.4.11 electron-debug: specifier: ^3.2.0 version: 3.2.0 @@ -138,6 +138,12 @@ importers: is-electron: specifier: ^2.2.2 version: 2.2.2 + kuroshiro: + specifier: ^1.2.0 + version: 1.2.0 + kuroshiro-analyzer-kuromoji: + specifier: ^1.1.0 + version: 1.1.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -278,8 +284,8 @@ importers: specifier: ^41.7.1 version: 41.7.2 electron-builder: - specifier: ^26.15.0 - version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + specifier: ^26.15.5 + version: 26.15.5(electron-builder-squirrel-windows@26.15.5) electron-devtools-installer: specifier: ^4.0.0 version: 4.0.0 @@ -2347,12 +2353,12 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} - app-builder-lib@26.15.3: - resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} + app-builder-lib@26.15.5: + resolution: {integrity: sha512-CJdzqy4YXQQdn+ivw1ssuY4yBTgVaBtniB2Dnjc6JsM9mbXoZ4shbuuysjenZloMOEIKEqkuRxltNQyG/NP/pA==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.15.3 - electron-builder-squirrel-windows: 26.15.3 + dmg-builder: 26.15.5 + electron-builder-squirrel-windows: 26.15.5 argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2409,6 +2415,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2877,8 +2886,8 @@ packages: discord-api-types@0.38.48: resolution: {integrity: sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==} - dmg-builder@26.15.3: - resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} + dmg-builder@26.15.5: + resolution: {integrity: sha512-Ts58Bs9QVCPhkhvkz9V1JwVoIwmbA06szZTM7W/ihzoDjHlf7KJo1Ci9nFknoFUC8uDeYgtbu5HW8eAeZ5qeSA==} doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -2897,8 +2906,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.10: - resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -2915,6 +2924,9 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + doublearray@0.0.2: + resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2939,11 +2951,11 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.15.3: - resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} + electron-builder-squirrel-windows@26.15.5: + resolution: {integrity: sha512-+7D6F08V26p8dLLu2rK4MReQR50lA5W6hEgtNmjm6xbLrAGCJSWFbQEca6oNRKnFGlfHGS1TfgHLmOL3HX+6DA==} - electron-builder@26.15.3: - resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} + electron-builder@26.15.5: + resolution: {integrity: sha512-ii+Befxc8diyoQv9iUchEzBAvFef4vrY/l2NID1wdZL2WCTLe80sYQz7Alc+yswWPpgowUdpsI5HtomE2Lj/Mg==} engines: {node: '>=14.0.0'} hasBin: true @@ -3948,6 +3960,16 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + kuromoji@0.1.2: + resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==} + + kuroshiro-analyzer-kuromoji@1.1.0: + resolution: {integrity: sha512-BSJFhpsQdPwfFLfjKxfLA9iL+/PC6LCR9vgwgb5Jc7jZwk9ilX8SAV6CwhAQZY611tiuhbB52ONYKDO8hgY1bA==} + + kuroshiro@1.2.0: + resolution: {integrity: sha512-yBGCK9oDOY3LGZ/KXaN9m7ADcAuSczOR2FoMRYwHLUlis3/o/uxdMVROAjENFO0NQJgALhIdWxI/vIBVrMCk9w==} + engines: {node: '>=6.5.0'} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -5335,8 +5357,8 @@ packages: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} undici@7.27.2: @@ -5700,6 +5722,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -7751,7 +7776,7 @@ snapshots: anynum@1.0.0: {} - app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): + app-builder-lib@26.15.5(dmg-builder@26.15.5)(electron-builder-squirrel-windows@26.15.5): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -7772,11 +7797,11 @@ snapshots: chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dmg-builder: 26.15.5(electron-builder-squirrel-windows@26.15.5) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-builder-squirrel-windows: 26.15.5(dmg-builder@26.15.5) electron-publish: 26.15.3 fs-extra: 10.1.0 hosted-git-info: 4.1.0 @@ -7876,6 +7901,10 @@ snapshots: async-function@1.0.0: {} + async@2.6.4: + dependencies: + lodash: 4.18.1 + async@3.2.6: {} asynckit@0.4.0: {} @@ -8381,9 +8410,9 @@ snapshots: discord-api-types@0.38.48: {} - dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dmg-builder@26.15.5(electron-builder-squirrel-windows@26.15.5): dependencies: - app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + app-builder-lib: 26.15.5(dmg-builder@26.15.5)(electron-builder-squirrel-windows@26.15.5) builder-util: 26.15.3 fs-extra: 10.1.0 js-yaml: 4.2.0 @@ -8412,7 +8441,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.10: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8432,6 +8461,8 @@ snapshots: dotenv@16.6.1: {} + doublearray@0.0.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8454,23 +8485,23 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): + electron-builder-squirrel-windows@26.15.5(dmg-builder@26.15.5): dependencies: - app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + app-builder-lib: 26.15.5(dmg-builder@26.15.5)(electron-builder-squirrel-windows@26.15.5) builder-util: 26.15.3 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + electron-builder@26.15.5(electron-builder-squirrel-windows@26.15.5): dependencies: - app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + app-builder-lib: 26.15.5(dmg-builder@26.15.5)(electron-builder-squirrel-windows@26.15.5) builder-util: 26.15.3 builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dmg-builder: 26.15.5(electron-builder-squirrel-windows@26.15.5) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -9721,6 +9752,20 @@ snapshots: known-css-properties@0.37.0: {} + kuromoji@0.1.2: + dependencies: + async: 2.6.4 + doublearray: 0.0.2 + zlibjs: 0.3.1 + + kuroshiro-analyzer-kuromoji@1.1.0: + dependencies: + kuromoji: 0.1.2 + + kuroshiro@1.2.0: + dependencies: + '@babel/runtime': 7.29.7 + lazy-val@1.0.5: {} lead@4.0.0: {} @@ -11204,7 +11249,7 @@ snapshots: undici@6.24.1: {} - undici@6.26.0: {} + undici@6.27.0: {} undici@7.27.2: {} @@ -11636,6 +11681,8 @@ snapshots: yocto-queue@0.1.0: {} + zlibjs@0.3.1: {} + zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/settings.js.template b/settings.js.template index a451e6d37..0414d71f5 100644 --- a/settings.js.template +++ b/settings.js.template @@ -80,6 +80,8 @@ window.FS_DISCORD_SHOW_STATE_ICON = "${FS_DISCORD_SHOW_STATE_ICON}"; window.FS_LYRICS_FETCH = "${FS_LYRICS_FETCH}"; window.FS_LYRICS_FOLLOW = "${FS_LYRICS_FOLLOW}"; window.FS_LYRICS_DELAY_MS = "${FS_LYRICS_DELAY_MS}"; +window.FS_LYRICS_LINE_LEAD_TIME_MS = "${FS_LYRICS_LINE_LEAD_TIME_MS}"; +window.FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT = "${FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT}"; window.FS_LYRICS_PREFER_LOCAL = "${FS_LYRICS_PREFER_LOCAL}"; window.FS_LYRICS_SHOW_MATCH = "${FS_LYRICS_SHOW_MATCH}"; window.FS_LYRICS_SHOW_PROVIDER = "${FS_LYRICS_SHOW_PROVIDER}"; @@ -87,6 +89,8 @@ window.FS_LYRICS_ENABLE_AUTO_TRANSLATION = "${FS_LYRICS_ENABLE_AUTO_TRANSLATION} window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}"; window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}"; window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}"; +window.FS_LYRICS_PADDING_LEFT = "${FS_LYRICS_PADDING_LEFT}"; +window.FS_LYRICS_PADDING_RIGHT = "${FS_LYRICS_PADDING_RIGHT}"; window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}"; window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}"; diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 6c74e0f79..cbc40957f 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -973,7 +973,47 @@ "autoDJ_albumStrategy": "Mode de selecció d'àlbum", "autoDJ_songStrategy": "Mode de selecció de cançó", "autoDJ_strategy_option_library_random": "A l'atzar", - "autoDJ_strategy_option_similar": "Similar" + "autoDJ_strategy_option_similar": "Similar", + "enableFurigana_description": "Mostra guies de pronunciació (furigana) per les lletres en japonès.", + "enableFurigana": "Activa la generació de furigana", + "equalizer_descriptionMpv": "Equalitzador paramètric amb FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Equalitzador paramètric amb l'API de Web Audio", + "equalizer": "Equalitzador", + "equalizerBands_description": "Guany per banda. Arrossegueu-lo amunt o avall, o introduïu-hi un valor. Rang: -12 a +12 dB.", + "equalizerBands": "Bandes", + "equalizerPreamp_description": "Guany d'entrada previ a les bandes de l'equalitzador. Poseu-lo en negatiu quan realceu les bandes per evitar el clipping (MPV).", + "equalizerPreamp": "Preamplificador", + "equalizerPreset_description": "Aplica una corba d'equalitzador personalitzada integrada o desada", + "equalizerPreset": "Preajustament", + "equalizerPresetDeletePlaceholder": "Elimina la personalització...", + "equalizerPresetGroupBuiltIn": "Integrat", + "equalizerPresetGroupCustom": "Personalitzat", + "equalizerPresetNamePlaceholder": "Nom de l'ajust predefinit...", + "equalizerPresetSelectPlaceholder": "Seleccioneu un ajust predefinit", + "equalizerSavePreset_description": "Desa la configuració actual de l'equalitzador com a ajust predefinit amb nom", + "equalizerSavePreset": "Desa l'ajust", + "compressor_descriptionMpv": "Compressor de rang dinàmic amb el compressor de FFmpeg (MPV)", + "compressor_descriptionWebAudio": "Compressor de rang dinàmic amb l'API de Web Audio", + "compressor": "Compressor", + "compressorAttack_description": "La rapidesa amb què el compressor s'activa quan el senyal excedeix el llindar.", + "compressorAttack": "Atac", + "compressorKnee_description": "Amplada de la zona de resposta suau. Com més alt sigui, més gradual serà la transició cap a la compressió.", + "compressorKnee": "Zona de resposta", + "compressorMakeupGain_description": "Guany de sortida aplicat després de la compressió per recuperar volum.", + "compressorMakeupGain": "Guany de compensació", + "compressorPreset_description": "Aplica una configuració personalitzada del compressor integrada o desada", + "compressorRatio_description": "Ràtio de compressio, p. ex. 4 = 4:1.", + "compressorRatio": "Ràtio", + "compressorRelease_description": "Com de ràpid el compressor es desactiva un cop el senyal sigui inferior al llindar.", + "compressorRelease": "Desactivació", + "compressorReset_description": "Restaura tots els paràmetres del compressor als seus valors per defecte", + "compressorSavePreset_description": "Desa la configuració actual del compressor com un ajust predefinit amb nom", + "compressorThreshold_description": "Nivell de senyal a partir del qual comença la compressió.", + "compressorThreshold": "Llindar", + "enableRomaji_description": "Mostra la pronunciació en romaji a sota de la lletra en japonès.", + "enableRomaji": "Activa la generació de romaji", + "windowBarTrackinfo": "Títol de la finestra d'informació de la pista", + "windowBarTrackinfo_description": "Mostra el títol i l'artista de la pista actual, la posició a la cua i l'estat de reproducció al títol de la finestra." }, "table": { "column": { @@ -1266,7 +1306,9 @@ "notContains": "No conté", "notInPlaylist": "No és a", "notInTheLast": "No és a l'últim", - "startsWith": "Comença amb" + "startsWith": "Comença amb", + "isMissing": "Falta", + "isPresent": "Està present" }, "queryBuilder": { "standardTags": "Etiquetes estàndard", diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json index 76dd6869f..263319661 100644 --- a/src/i18n/locales/cs.json +++ b/src/i18n/locales/cs.json @@ -265,7 +265,7 @@ "lastfmApiKey_description": "Klíč API pro {{lastfm}}. Vyžadováno pro obaly alb", "discordServeImage": "Načítat obrázky {{discord}} ze serveru", "discordServeImage_description": "Sdílet obaly alb pro {{discord}} rich presence ze samotného serveru, dostupné pouze pro Jellyfin a Navidrome. {{discord}} používá bota pro získávání obrázků, váš server tudíž musí být dosažitelný z veřejného internetu", - "lastfm": "Zobrazit odkazy na last.fm", + "lastfm": "Zobrazit odkazy na Last.fm", "lastfm_description": "Na stránkách umělců a alb zobrazit odkazy na Last.fm", "musicbrainz": "Zobrazit odkazy na MusicBrainz", "musicbrainz_description": "Na stránkách umělců a alb, kde existuje MusicBrainz ID, zobrazit odkazy na MusicBrainz", @@ -457,7 +457,50 @@ "autoDJ_albumStrategy": "Režim výběru alb", "autoDJ_songStrategy": "Režim výběru skladeb", "autoDJ_strategy_option_library_random": "Náhodně", - "autoDJ_strategy_option_similar": "Podobné" + "autoDJ_strategy_option_similar": "Podobné", + "enableFurigana_description": "Zobrazit návody na výslovnost (furigana) u japonských kandži textů.", + "enableFurigana": "Povolit generování furigana", + "equalizer_descriptionMpv": "Parametrický ekvalizér skrze FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Parametrický ekvalizér skrze Web Audio API", + "equalizer": "Ekvalizér", + "equalizerBands_description": "Zisk na pásmo. Posuňte nahoru/dolů nebo zadejte hodnotu. Rozsah: -12 do +12 dB.", + "equalizerBands": "Pásma", + "equalizerPreamp_description": "Vstupní zisk před pásmy ekvalizéru. Při zvýšení pásem nastavte na negativní hodnotu pro zabránění clippingu (MPV).", + "equalizerPreamp": "Předzesilovač", + "equalizerPreset_description": "Použít vestavěnou nebo uloženou vlastní křivku ekvalizéru", + "equalizerPreset": "Předvolba", + "equalizerPresetDeletePlaceholder": "Odstranit vlastní…", + "equalizerPresetGroupBuiltIn": "Vestavěná", + "equalizerPresetGroupCustom": "Vlastní", + "equalizerPresetNamePlaceholder": "Název předvolby…", + "equalizerPresetSelectPlaceholder": "Vybrat předvolbu", + "equalizerSavePreset_description": "Uložit aktuální nastavení ekvalizéru jako pojmenovanou předvolbu", + "equalizerSavePreset": "Uložit předvolbu", + "compressor_descriptionMpv": "Kompresor dynamického rozsahu skrze FFmpeg acompressor (MPV)", + "compressor_descriptionWebAudio": "Kompresor dynamického rozsahu skrze Web Audio API", + "compressor": "Kompresor", + "compressorAttack_description": "Jak rychle se kompresor spustí, když signál překročí hranici.", + "compressorAttack": "Útok", + "compressorKnee_description": "Měkká šířka. Čím vyšší jsou hodnoty, tím pozvolnější je přechod do komprese.", + "compressorKnee": "Koleno", + "compressorMakeupGain_description": "Výstupní zesílení aplikované po kompresi za účelem obnovení hlasitosti.", + "compressorMakeupGain": "Následný zisk", + "compressorPreset_description": "Použít vestavěné nebo uložené vlastní nastavení kompresoru", + "compressorRatio_description": "Poměr komprese, např. 4 = 4:1.", + "compressorRatio": "Poměr", + "compressorRelease_description": "Jak rychle se kompresor uvolní, když signál spadne pod nastavenou hranici.", + "compressorRelease": "Uvolnění", + "compressorReset_description": "Obnovit všechny parametry kompresoru na jejich výchozí hodnoty", + "compressorSavePreset_description": "Uložit aktuální nastavení kompresoru jako pojmenovanou předvolbu", + "compressorThreshold_description": "Úroveň signálu, nad kterou začne komprese.", + "compressorThreshold": "Hranice", + "enableRomaji_description": "Zobrazit rómadži výslovnost pod japonskými texty.", + "enableRomaji": "Povolit generování rómadži", + "windowBarTrackinfo_description": "Zobrazit v titulku okna umělce a název aktuální skladby, její pozici ve frontě a stav Přehrává se/Pozastaveno.", + "windowBarTrackinfo": "Informace o skladbě v titulku okna", + "audioDeviceDefault": "Podle systému", + "microtonalPitchControls": "Ovládání mikrotonální výšky tónu", + "microtonalPitchControls_description": "Přidat další ovládání výšky mezi půltóny" }, "action": { "editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})", @@ -674,7 +717,9 @@ "horizontalBorders": "Okraje řádků", "rowHoverHighlight": "Zvýraznění řádku při přejetí myší", "verticalBorders": "Okraje sloupců", - "showHeader": "Zobrazit záhlaví" + "showHeader": "Zobrazit záhlaví", + "albumImageSize": "Velikost obrázku alba", + "albumGroupConfig": "Nastavení skupiny alb" }, "label": { "releaseDate": "Datum vydání", @@ -863,7 +908,11 @@ "dynamicIsImage": "Povolit obrázek na pozadí", "lyricOffset": "Posunutí textů (ms)", "lyricOpacityNonActive": "Neprůhlednost neaktivních textů", - "lyricScaleNonActive": "Velikost neaktivních textů" + "lyricScaleNonActive": "Velikost neaktivních textů", + "lyricFollowScrollAlignment": "Zarovnání sledování textů", + "lyricLineLeadTime": "Doba vedení řádku (ms)", + "lyricPaddingLeft": "Odsazení textů zleva (%)", + "lyricPaddingRight": "Odsazení textů zprava (%)" }, "upNext": "Další", "lyrics": "Texty", @@ -1071,7 +1120,8 @@ "input_preferInstantMixDescription": "Pro získání podobných skladeb použít pouze instantní mix. užitečné, pokud máte doplňky, které upravují toto chování", "input_preferRemoteUrl": "Preferovat veřejnou adresu url", "input_remoteUrl": "Veřejná adresa url", - "input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce" + "input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce", + "input_passwordNoSSO": "Autentifikace skrze reverzní proxy (SSO) není podporována. Podporována je pouze autentifikace pomocí uživatelského jména a hesla s Subsonic" }, "addToPlaylist": { "success": "$t(entity.trackWithCount, {\"count\": {{message}} }) přidáno do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", @@ -1270,7 +1320,9 @@ "notContains": "Neobsahuje", "notInPlaylist": "Není v", "notInTheLast": "Není v posledním", - "startsWith": "Začíná na" + "startsWith": "Začíná na", + "isMissing": "Chybí", + "isPresent": "Je přítomen" }, "datetime": { "minuteShort": "min.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cccad1829..3ec9efd9a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -314,6 +314,8 @@ "inTheRangeDate": "Is in the range (date)", "is": "Is", "isNot": "Is not", + "isMissing": "Is missing", + "isPresent": "Is present", "isGreaterThan": "Is greater than", "isLessThan": "Is less than", "matchesRegex": "Matches regex", @@ -330,6 +332,7 @@ "input_legacyAuthentication": "Enable legacy authentication", "input_name": "Server Name", "input_password": "Password", + "input_passwordNoSSO": "Reverse proxy (SSO) authentication is not supported. Only username/password authentication and Subsonic authentication are supported", "input_preferInstantMix": "Prefer Instant Mix", "input_preferInstantMixDescription": "Only use instant mix to get similar songs. Useful if you have plugins that modify this behavior", "input_preferRemoteUrl": "Prefer Public URL", @@ -547,9 +550,13 @@ "dynamicImageBlur": "Image blur size", "dynamicIsImage": "Enable background image", "followCurrentLyric": "Follow current lyric", + "lyricFollowScrollAlignment": "Lyrics follow alignment", "lyricAlignment": "Lyric alignment", "lyricOffset": "Lyrics offset (ms)", "lyricGap": "Lyric gap", + "lyricLineLeadTime": "Line lead time (ms)", + "lyricPaddingLeft": "Lyrics left padding (%)", + "lyricPaddingRight": "Lyrics right padding (%)", "lyricSize": "Lyric size", "lyricOpacityNonActive": "Non-active lyric opacity", "lyricScaleNonActive": "Non-active lyric scale", @@ -561,6 +568,10 @@ "useImageAspectRatio": "Use image aspect ratio" }, "lyrics": "Lyrics", + "lyricLayers": "Lyric layers", + "lyricLanguage": "Language", + "showPronunciation": "Pronunciation", + "showTranslation": "Translation", "related": "Related", "upNext": "Up next", "visualizer": "Visualizer", @@ -797,6 +808,7 @@ "artistConfiguration_description": "Configure what items are shown, and in what order, on the album artist page", "artistReleaseTypeConfiguration": "Artist release type configuration", "artistReleaseTypeConfiguration_description": "Configure what release types are shown, and in what order, on the album artist page", + "audioDeviceDefault": "System default", "audioDevice_description": "Select the audio device to use for playback", "audioDevice": "Audio device", "audioExclusiveMode_description": "Enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio. Visualizer system audio capture will not work while this is enabled", @@ -857,6 +869,26 @@ "discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)", "enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded", "enableAutoTranslation": "Enable auto translation", + "enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.", + "enableFurigana": "Enable furigana generation", + "enableRomaji_description": "Display a romaji pronunciation line under Japanese lyrics.", + "enableRomaji": "Enable romaji generation", + "equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API", + "equalizer": "Equalizer", + "equalizerBands_description": "Per-band gain. Drag up/down or type a value. Range: -12 to +12 dB.", + "equalizerBands": "Bands", + "equalizerPreamp_description": "Input gain before EQ bands. Set negative when boosting bands to prevent clipping (MPV).", + "equalizerPreamp": "Preamp", + "equalizerPreset_description": "Apply a built-in or saved custom EQ curve", + "equalizerPreset": "Preset", + "equalizerPresetDeletePlaceholder": "Delete custom...", + "equalizerPresetGroupBuiltIn": "Built-in", + "equalizerPresetGroupCustom": "Custom", + "equalizerPresetNamePlaceholder": "Preset name...", + "equalizerPresetSelectPlaceholder": "Select preset", + "equalizerSavePreset_description": "Save the current EQ settings as a named preset", + "equalizerSavePreset": "Save preset", "enableRemote_description": "Enables the remote control server to allow other devices to control the application", "enableRemote": "Enable remote control server", "exitToTray_description": "Exit the application to the system tray", @@ -1038,6 +1070,24 @@ "showVisualizerInSidebar": "Show visualizer in player sidebar", "combinedLyricsAndVisualizer_description": "Combine lyrics and visualizer into the same panel", "combinedLyricsAndVisualizer": "Combine lyrics and visualizer in player sidebar", + "compressor_descriptionMpv": "Dynamic range compressor via FFmpeg acompressor (MPV)", + "compressor_descriptionWebAudio": "Dynamic range compressor via Web Audio API", + "compressor": "Compressor", + "compressorAttack_description": "How quickly the compressor engages after the signal exceeds the threshold.", + "compressorAttack": "Attack", + "compressorKnee_description": "Soft-knee width. Higher values make the transition into compression more gradual.", + "compressorKnee": "Knee", + "compressorMakeupGain_description": "Output gain applied after compression to restore loudness.", + "compressorMakeupGain": "Makeup Gain", + "compressorPreset_description": "Apply a built-in or saved custom compressor setting", + "compressorRatio_description": "Compression ratio, e.g. 4 = 4:1.", + "compressorRatio": "Ratio", + "compressorRelease_description": "How quickly the compressor releases after the signal drops below the threshold.", + "compressorRelease": "Release", + "compressorReset_description": "Restore all compressor parameters to their default values", + "compressorSavePreset_description": "Save the current compressor settings as a named preset", + "compressorThreshold_description": "Signal level above which compression begins.", + "compressorThreshold": "Threshold", "preservePitch_description": "Preserves pitch when modifying playback speed", "preservePitch": "Preserve pitch", "audioFadeOnStatusChange": "Audio fade on status change", @@ -1151,13 +1201,17 @@ "webAudio": "Use web audio", "windowBarStyle_description": "Select the style of the window bar", "windowBarStyle": "Window bar style", + "windowBarTrackinfo": "Track info in Window Title", + "windowBarTrackinfo_description": "Show current track's title and artist, queue position, and Playing/Paused state in the Window's Title.", "zoom_description": "Sets the zoom percentage for the application", "zoom": "Zoom percentage", "queryBuilder": "Query builder", "queryBuilderCustomFields_inputLabel": "Label", "queryBuilderCustomFields_inputTag": "Tag", "queryBuilderCustomFields": "Custom fields", - "queryBuilderCustomFields_description": "Add custom fields to use in query builders" + "queryBuilderCustomFields_description": "Add custom fields to use in query builders", + "microtonalPitchControls": "Microtonal pitch controls", + "microtonalPitchControls_description": "Add more pitch controls to go between semi-tones" }, "table": { "column": { @@ -1192,6 +1246,10 @@ "config": { "general": { "advancedSettings": "Advanced settings", + "albumGroupConfig": "Album Group configuration", + "albumGroupMetadata": "Metadata fields", + "albumGroupShowFavoriteRating": "Show favorites and ratings", + "albumImageSize": "Album image size", "autoFitColumns": "Auto fit columns", "autosize": "Autosize", "moveUp": "Move up", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0b2a468a2..97fb411ca 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -457,7 +457,50 @@ "autoDJ_songStrategy": "Modo de selección de canción", "autoDJ_strategy_option_library_random": "Aleatorio", "autoDJ_strategy_option_similar": "Similar", - "autoDJ_mode_description": "Elegir para añadir canciones o álbumes enteros a la cola" + "autoDJ_mode_description": "Elegir para añadir canciones o álbumes enteros a la cola", + "enableFurigana_description": "Mostrar guías de pronunciación (furigana) sobre letras kanji japonesas.", + "enableFurigana": "Activar generación de furigana", + "equalizer_descriptionMpv": "Ecualizador paramétrico a través de FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Ecualizador paramétrico a través de la API de Web Audio", + "equalizer": "Ecualizador", + "equalizerBands": "Bandas", + "equalizerBands_description": "Ganancia por banda. Arrastrar arriba/abajo o escribir un valor. Rango: -12 a +12 dB.", + "equalizerPreamp_description": "Ganancia de entrada antes de las bandas de ecualización. Ajústala en negativo al realzar las bandas para evitar el clipping (MPV).", + "equalizerPreamp": "Preamplificador", + "equalizerPreset_description": "Aplica una curva de ecualizador personalizada integrada o guardada", + "equalizerPreset": "Preajuste", + "equalizerPresetDeletePlaceholder": "Elimianr personalizado...", + "equalizerPresetGroupBuiltIn": "Integrado", + "equalizerPresetGroupCustom": "Personalizado", + "equalizerPresetNamePlaceholder": "Nombre del preajuste...", + "equalizerPresetSelectPlaceholder": "Seleccionar preajuste", + "equalizerSavePreset": "Guardar preajuste", + "equalizerSavePreset_description": "Guarda la configuración del ecualizador actual como un preajuste nombrado", + "compressor_descriptionMpv": "Compresor de rango dinámico a través del compresor de FFmpeg (MPV)", + "compressor_descriptionWebAudio": "Compresor de rango dinámico a través de la API de Web Audio", + "compressor": "Compresor", + "compressorThreshold": "Umbral", + "compressorThreshold_description": "Nivel de señal a partir del cual comienza la compresión.", + "compressorSavePreset_description": "Guarda la configuración actual del compresor como un preajuste nombrado", + "compressorReset_description": "Restaura todos los parámetros del compresor a sus valores predeterminados", + "compressorRelease": "Liberación", + "compressorRelease_description": "La rapidez con la que el compresor se libera una vez que la señal desciende por debajo del umbral.", + "compressorRatio": "Ratio", + "compressorRatio_description": "Ratio de compresión, p. ej. 4 = 4:1.", + "compressorPreset_description": "Aplica una configuración personalizada del compresor integrada o guardada", + "compressorKnee_description": "Ancho de la zona de respuesta suave. Cuanto mayor sea el valor, más gradual será la transición hacia la compresión.", + "compressorKnee": "Zona de respuesta", + "compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.", + "compressorMakeupGain": "Ganancia de compensación", + "compressorAttack_description": "La rapidez con la que el compresor entra en acción una vez que la señal supera el umbral.", + "compressorAttack": "Ataque", + "enableRomaji_description": "Muestra una línea de pronunciación en romaji debajo de las letras japonesas.", + "enableRomaji": "Activar generación de romaji", + "windowBarTrackinfo": "Información de la pista en el título de la ventana", + "windowBarTrackinfo_description": "Muestra el título y artista de la pista actual, posición en la cola, y estado reproduciendo/pausado en el título de la ventana.", + "audioDeviceDefault": "Predeterminado del sistema", + "microtonalPitchControls": "Controles de tono microtonales", + "microtonalPitchControls_description": "Añade más controles de tono para moverse entre semitonos" }, "action": { "editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})", @@ -810,7 +853,11 @@ "dynamicIsImage": "Habilitar imagen de fondo", "lyricOffset": "Desplazamiento de letras (ms)", "lyricScaleNonActive": "Escala de letra no activa", - "lyricOpacityNonActive": "Opacidad de letra no activa" + "lyricOpacityNonActive": "Opacidad de letra no activa", + "lyricPaddingLeft": "Relleno a la izquierda de las letras (%)", + "lyricPaddingRight": "Relleno a la derecha de las letras (%)", + "lyricLineLeadTime": "Tiempo de respuesta de la línea (ms)", + "lyricFollowScrollAlignment": "Las letras siguen la alineación" }, "lyrics": "Letras", "related": "Relacionado", @@ -962,7 +1009,8 @@ "input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento", "input_remoteUrl": "URL Pública", "input_preferRemoteUrl": "Preferir URL Pública", - "input_remoteUrlPlaceholder": "Opcional: URL pública para características externas" + "input_remoteUrlPlaceholder": "Opcional: URL pública para características externas", + "input_passwordNoSSO": "La autenticación de proxy inverso (SSO) no está soportada. Solo está soportada la autenticación por usuario/contraseña y la autenticación de Subsonic" }, "addToPlaylist": { "success": "Añadido $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", @@ -1149,7 +1197,9 @@ "horizontalBorders": "Bordes de fila", "verticalBorders": "Bordes de columna", "rowHoverHighlight": "Resaltar al pasar el cursor por la fila", - "showHeader": "Mostrar cabecera" + "showHeader": "Mostrar cabecera", + "albumImageSize": "Tamaño de la imagen del álbum", + "albumGroupConfig": "Configuración del grupo del álbum" }, "view": { "table": "Tabla", @@ -1270,7 +1320,9 @@ "notInPlaylist": "No está en", "notInTheLast": "No está en el último", "startsWith": "Empieza con", - "matchesRegex": "Coincide con expresión regular" + "matchesRegex": "Coincide con expresión regular", + "isPresent": "Está presente", + "isMissing": "Falta" }, "datetime": { "minuteShort": "M", diff --git a/src/i18n/locales/et.json b/src/i18n/locales/et.json index 6eee3aa2f..c196e47a6 100644 --- a/src/i18n/locales/et.json +++ b/src/i18n/locales/et.json @@ -314,7 +314,9 @@ "after": "On pärast", "notInPlaylist": "Ei ole", "startsWith": "Algab", - "notInTheLast": "Pole viimase" + "notInTheLast": "Pole viimase", + "isMissing": "Puudub", + "isPresent": "On olemas" }, "form": { "addServer": { @@ -456,7 +458,7 @@ "title": "$t(entity.albumArtist, {\"count\": 2})" }, "albumDetail": { - "moreFromArtist": "Veel esitajalt $t(entity.artist, {\"count\": 1})", + "moreFromArtist": "Veel $t(entity.artist, {\"count\": 1}) albumeid", "moreFromGeneric": "Veel: {{item}}", "released": "Avaldatud" }, @@ -1133,7 +1135,43 @@ "queryBuilderCustomFields_inputLabel": "Nimetus", "queryBuilderCustomFields_inputTag": "Silt", "queryBuilderCustomFields": "Kohandatud väljad", - "queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada" + "queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada", + "equalizer_descriptionMpv": "Parametriline ekvalaiser FFmpeg lavfi (MPV) kaudu", + "equalizer_descriptionWebAudio": "Parametriline ekvalaiser Web Audio API kaudu", + "equalizer": "Ekvalaiser", + "equalizerBands_description": "Riba põhivõimendus. Lohista üles/alla või sisesta väärtus. Vahemik: -12 kuni +12 dB.", + "equalizerBands": "Ribad", + "equalizerPreamp_description": "Sisendvõimendus enne ekvalaiseri ribasid. Moonutuste vältimiseks määra ribade võimendamisel negatiivne väärtus (MPV).", + "equalizerPreamp": "Eelvõimendus", + "equalizerPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud EQ-häälestus", + "equalizerPreset": "Eelseadistus", + "equalizerPresetDeletePlaceholder": "Kustuta kohandatud...", + "equalizerPresetGroupBuiltIn": "Sisseehitatud", + "equalizerPresetGroupCustom": "Kohandatud", + "equalizerPresetNamePlaceholder": "Eelseadistuse nimi...", + "equalizerPresetSelectPlaceholder": "Vali eelseadistus", + "equalizerSavePreset_description": "Salvesta praegused EQ-seaded nimetatud eelseadistusena", + "equalizerSavePreset": "Salvesta eelseadistus", + "compressor_descriptionMpv": "Dünaamilise vahemiku kompressor FFmpeg acompressori kaudu (MPV)", + "compressor_descriptionWebAudio": "Dünaamilise vahemiku kompressor Web Audio API kaudu", + "enableFurigana_description": "Kuva jaapani kanji-märkide kohal hääldusjuhiseid (furigana).", + "enableFurigana": "Luba furigana kuvamine", + "compressor": "Kompressor", + "compressorAttack_description": "Kui kiiresti kompressor pärast läve ületamist rakendub.", + "compressorAttack": "Rakendumisaeg", + "compressorKnee_description": "Sujuva ülemineku (soft-knee) ulatus. Suuremad väärtused muudavad kompressiooni rakendumise astmelisemaks.", + "compressorKnee": "Üleminek", + "compressorMakeupGain_description": "Väljundvõimendus helitugevuse taastamiseks pärast kompressiooni.", + "compressorMakeupGain": "Väljundvõimendus", + "compressorPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud kompressoriseadistus", + "compressorRatio_description": "Kompressiooniaste, nt 4 = 4:1.", + "compressorRatio": "Kompressiooniaste", + "compressorRelease_description": "Kui kiiresti kompressiooni mõju pärast lävest allapoole langemist lakkab.", + "compressorRelease": "Vabastusaeg", + "compressorReset_description": "Taasta kõigi kompressori parameetrite vaikeväärtused", + "compressorSavePreset_description": "Salvesta praegused kompressori seaded nimetatud eelseadistusena", + "compressorThreshold_description": "Signaali tase, mida ületades kompressioon algab.", + "compressorThreshold": "Lävi" }, "datetime": { "minuteShort": "m", @@ -1358,7 +1396,8 @@ "horizontalBorders": "Ridade piirjooned", "rowHoverHighlight": "Rea esiletõstmine kursoriga", "showHeader": "Kuva päis", - "verticalBorders": "Veergude piirjooned" + "verticalBorders": "Veergude piirjooned", + "albumImageSize": "Kaanepildi suurus" }, "label": { "actions": "$t(common.action, {\"count\": 2})", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bfb37184f..629c20b6d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -674,9 +674,9 @@ "playButtonBehavior_optionAddNext": "$t(player.addNext)", "replayGainMode_description": "Ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier", "replayGainFallback": "Valeur de repli de {{ReplayGain}}", - "replayGainClipping_description": "Empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain", + "replayGainClipping_description": "Prévient l'écrêtage causé par {{ReplayGain}} en réduisant automatiquement le gain", "replayGainPreamp": "Préamplificateur (db) de {{ReplayGain}}", - "replayGainClipping": "Distorsion du {{ReplayGain}}", + "replayGainClipping": "Écrêtage du {{ReplayGain}}", "replayGainMode": "Mode de {{ReplayGain}}", "replayGainFallback_description": "Gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}", "replayGainPreamp_description": "Ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}", @@ -684,7 +684,7 @@ "clearCache": "Vider le cache du navigateur", "buttonSize_description": "La taille des boutons de la barre de lecture", "clearQueryCache_description": "Un 'nettoyage léger' de Feishin. Cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés", - "clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images sauvegardées et autres ressources). Les identifiants serveurs et paramètres sont conservés", + "clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images enregistrée et autres ressources). Les identifiants serveurs et paramètres sont conservés", "buttonSize": "Taille des boutons de la barre de lecture", "clearCacheSuccess": "Cache vidé avec succès", "externalLinks_description": "Activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album", @@ -926,7 +926,31 @@ "autoDJ_albumStrategy": "Mode de sélection d'album", "autoDJ_songStrategy": "Mode de sélection de titre", "autoDJ_strategy_option_library_random": "Aléatoire", - "autoDJ_strategy_option_similar": "Similaire" + "autoDJ_strategy_option_similar": "Similaire", + "enableFurigana_description": "Afficher les indications de prononciation (furigana) au-dessus des paroles en kanji Japonais.", + "enableFurigana": "Active la génération des furigana", + "enableRomaji_description": "Afficher une ligne de prononciation en romaji sous les paroles Japonaises.", + "enableRomaji": "Active la génération des romaji", + "equalizer_descriptionMpv": "Égaliseur paramétrique via FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Égaliseur paramétrique via Web Audio API", + "equalizer": "Égaliseur", + "equalizerBands_description": "Gain par bande. Faire glisser vers le haut/bas ou taper une valeur. Plage : -12 à +12 dB.", + "equalizerBands": "Bandes", + "equalizerPreamp_description": "Gain d'entrée avant les bandes d'égalisation. Réglez-le sur une valeur négative lors de l'amplification des bandes pour éviter l'écrêtage (MPV).", + "equalizerPreamp": "Préampli", + "equalizerPreset_description": "Appliquer une courbe d'EQ personnalisé intégrée ou personnalisée", + "equalizerPreset": "Préréglage", + "equalizerPresetDeletePlaceholder": "Supprimer les personnalisé...", + "equalizerPresetGroupBuiltIn": "Intégré", + "equalizerPresetGroupCustom": "Personnalisé", + "equalizerPresetNamePlaceholder": "Nom de préréglage...", + "equalizerPresetSelectPlaceholder": "Sélectionnée un préréglage", + "equalizerSavePreset_description": "Enregistrer les paramètres d'EQ actuels en tant que préréglage nommé", + "equalizerSavePreset": "Enregistrer le préréglage", + "sidebarPlaylistFolderTreeLineColor_description": "Couleur des lignes de l'arborescence (vide pour le thème par défaut)", + "sidebarPlaylistFolderTreeLineColor": "Couleur de la ligne de l'arboresence", + "windowBarTrackinfo": "Informations de la piste dans le titre de la fenêtre", + "windowBarTrackinfo_description": "Afficher le titre et l'artiste de la piste en cours, sa position dans la file d'attente et son état (lecture/pause) dans le titre de la fenêtre." }, "form": { "deletePlaylist": { @@ -945,12 +969,13 @@ "input_savePassword": "Enregister le mot de passe", "ignoreSsl": "Ignorer ssl $t(common.restartRequired)", "ignoreCors": "Ignorer cors $t(common.restartRequired)", - "error_savePassword": "Une erreur s’est produite lors de la tentative de sauvegarde du mot de passe", + "error_savePassword": "Une erreur s’est produite lors de la tentative d'enregistrement du mot de passe", "input_preferInstantMix": "Préférer le mix instantané", "input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement", "input_preferRemoteUrl": "Préférer une URL publique", "input_remoteUrl": "URL publique", - "input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes" + "input_remoteUrlPlaceholder": "Optionnel : URL publique pour les fonctionnalités externes", + "input_passwordNoSSO": "L'authentification par proxy inverse (SSO) n'est pas prise en charge. Seules l'authentification par nom d'utilisateur/mot de passe et l'authentification Subsonic sont prises en charge" }, "addToPlaylist": { "success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", @@ -1138,7 +1163,9 @@ "horizontalBorders": "Bordures de ligne", "rowHoverHighlight": "Surligner les lignes au survol", "verticalBorders": "Bordure de colonne", - "showHeader": "Affiche l'en-tête" + "showHeader": "Affiche l'en-tête", + "albumGroupConfig": "Configuration du groupe d'albums", + "albumImageSize": "Taille de la pochette d'album" }, "view": { "table": "Liste", @@ -1266,7 +1293,9 @@ "notContains": "Ne contient pas", "notInPlaylist": "N'est pas dans", "notInTheLast": "N'est pas dans le dernier", - "startsWith": "Commence par" + "startsWith": "Commence par", + "isMissing": "Est manquant", + "isPresent": "Est présent" }, "datetime": { "minuteShort": "M", diff --git a/src/i18n/locales/hu.json b/src/i18n/locales/hu.json index fd678261a..977233d07 100644 --- a/src/i18n/locales/hu.json +++ b/src/i18n/locales/hu.json @@ -7,7 +7,10 @@ "viewPlaylists": "$t(entity.playlist, {\"count\": 2}) megtekintése", "openIn": { "lastfm": "Megnyitás Last.fm-ben", - "musicbrainz": "Megnyitás MusicBrainz-ben" + "musicbrainz": "Megnyitás MusicBrainz-ben", + "listenbrainz": "Megnyitás ListenBrainz-ben", + "qobuz": "Megnyitás Qobuz_ban", + "spotify": "Megnyitás Spotify-ban" }, "clearQueue": "Műsorlista kiürítése", "createPlaylist": "$t(entity.playlist, {\"count\": 1}) létrehozása", @@ -117,7 +120,7 @@ "none": "Egyik sem", "restartRequired": "Újraindítás szükséges", "setting_one": "Beállítás", - "setting_other": "", + "setting_other": "Beállítások", "translation": "Fordítás", "rating": "Értékelés", "reload": "Újratöltés", @@ -154,7 +157,14 @@ "view": "Nézet", "noFilters": "Nincs konfigurált szűrő", "countSelected": "{{count}} kiválasztott", - "retry": "Újra" + "retry": "Újra", + "openFolder": "Mappa megnyitás", + "example": "Példa", + "filter_single": "Egy", + "filter_multiple": "Több", + "mood": "Hangulat", + "numberOfResults": "{{numberOfResults}} eredmény", + "grouping": "Csoportosítás" }, "entity": { "albumArtist_one": "Zenész", @@ -207,8 +217,8 @@ "openError": "A fájl megnyitása sikertelen volt", "playbackError": "Hiba történt a média lejátszásakor", "remoteEnableError": "Hiba történt a távoli szerver műveletkor: $t(common.enable)", - "remotePortError": "Hiba történt a távoli szerver PORT-jának beállításakor", - "remotePortWarning": "Indítsd újra a szervert az új PORT használatához", + "remotePortError": "Hiba történt a távoli szerver port-jának beállításakor", + "remotePortWarning": "Indítsd újra a szervert az új port használatához", "genericError": "Hiba történt", "endpointNotImplementedError": "A(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}", "badAlbum": "Azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít", @@ -226,7 +236,10 @@ "noNetworkDescription": "Nem tudok csatlakozni a szerverhez", "saveQueueFailed": "Műsorlista mentése sikertelen", "settingsSyncError": "Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást", - "multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott" + "multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott", + "invalidJson": "Érvénytelen JSON", + "playbackPausedDueToError": "Lejátszás szüneteltetve hiba miatt", + "serverLockSingleServer": "A szerver zárolása esetén csak egy szerver engedélyezett" }, "filter": { "albumCount": "$t(entity.album, {\"count\": 2}) darab", @@ -269,9 +282,11 @@ "trackNumber": "Sáv", "artist": "$t(entity.artist, {\"count\": 1})", "bpm": "Bpm", - "channels": "$t(common.channel_other)", + "channels": "$t(common.channel, {\"count\": 2})", "genre": "$t(entity.genre, {\"count\": 1})", - "explicitStatus": "$t(common.explicitStatus)" + "explicitStatus": "$t(common.explicitStatus)", + "matchAnd": "és", + "matchOr": "vagy" }, "form": { "addServer": { @@ -671,7 +686,7 @@ "customFontPath_description": "Beállítja az alkalmazáshoz használandó egyéni betűtípus elérési útját", "contextMenu": "Kontextusmenü (jobb klikk) beállítás", "crossfadeDuration_description": "Beállítja áthúzás effekt időtartamát", - "crossfadeDuration": "Áthúzás IDőtartam", + "crossfadeDuration": "Áthúzás Idő tartam", "crossfadeStyle": "Áthúzás stílus", "crossfadeStyle_description": "Válaszd ki az audiolejátszóhoz használni kívánt áthúzás stílust", "releaseChannel_description": "Válassz a stabil kiadás vagy a béta kiadás közül az automatikus frissítésekhez", @@ -852,7 +867,7 @@ "sidebarPlaylistList_description": "Lejátszási lista megjelenítése vagy elrejtése az oldalsávban", "sidebarPlaylistList": "Oldalsáv lejátszási lista", "sidePlayQueueStyle_description": "Beállítja az oldalsó műsorlista stílusát", - "mediaSession_description": "Lehetővé teszi a Windows Media Session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn (csak Windows)", + "mediaSession_description": "Bekapcsolja a media session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn. (Web audiolejátszó szükséges.)", "mediaSession": "Média munkamenet engedélyezése", "sidePlayQueueStyle": "Oldalsó műsorlista stílus", "skipDuration": "Átugrás hossza", @@ -918,7 +933,7 @@ "autoDJ": "Auto DJ", "autoDJ_timing": "Időzítés", "autoDJ_itemCount": "Elem szám", - "autoDJ_itemCount_description": "Az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma", + "autoDJ_itemCount_description": "A műsorsorba felvenni kívánt elemek száma", "autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma", "followCurrentSong_description": "Automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra", "followCurrentSong": "Kövesd az aktuális dalt", @@ -959,13 +974,13 @@ "biography": "$t(common.biography)", "bitrate": "$t(common.bitrate)", "bpm": "$t(common.bpm)", - "channels": "$t(common.channel_other)", + "channels": "$t(common.channel, {\"count\": 2})", "codec": "$t(common.codec)", "dateAdded": "Hozzáadva", "discNumber": "Lemezszám", "duration": "$t(common.duration)", "favorite": "$t(common.favorite)", - "actions": "$t(common.action_other)", + "actions": "$t(common.action, {\"count\": 2})", "album": "$t(entity.album, {\"count\": 1})", "albumCount": "$t(entity.album, {\"count\": 2})", "genreBadge": "$t(entity.genre, {\"count\": 1}) (jelvények)", @@ -1011,33 +1026,33 @@ } }, "column": { - "albumCount": "$t(entity.album, {\"count\": 2})", - "artist": "$t(entity.artist, {\"count\": 1})", + "albumCount": "Albumok", + "artist": "Előadó", "biography": "Életrajz", "bitrate": "Bitráta", "bpm": "BPM", - "channels": "$t(common.channel_other)", + "channels": "Csatornák", "codec": "$t(common.codec)", "comment": "Komment", "dateAdded": "Hozzáadva", "discNumber": "Lemez", "favorite": "Kedvenc", - "genre": "$t(entity.genre, {\"count\": 1})", + "genre": "Műfaj", "lastPlayed": "Utoljára játszott", "path": "Elérési út", "playCount": "Lejátszások", "rating": "Értékelés", "releaseDate": "Megjelenés", "releaseYear": "Év", - "size": "$t(common.size)", - "songCount": "$t(entity.track, {\"count\": 2})", + "size": "Méret", + "songCount": "Sávok", "title": "Cím", "trackNumber": "Sáv", "album": "Album", "albumArtist": "Album előadó", "owner": "Tulajdonos", - "bitDepth": "$t(common.bitDepth)", - "sampleRate": "$t(common.sampleRate)" + "bitDepth": "Bitmélység", + "sampleRate": "Mintavételi frekvencia" } }, "queryBuilder": { @@ -1070,5 +1085,29 @@ "secondShort": "Mp", "hourShort": "Óra", "dayShort": "Nap" + }, + "visualizer": { + "options": { + "weightingFilter": { + "a": "A", + "b": "B", + "c": "C", + "d": "D", + "z": "Z", + "none": "Nincs" + }, + "mode": { + "1": "[1] 1/24 oktáv / 240 sáv", + "2": "[2] 1/12 oktáv / 120 sáv", + "3": "[3] 1/8 oktáv / 80 sáv", + "4": "[4] 1/6 oktáv / 60 sáv", + "5": "[5] 1/4 oktáv / 40 sáv", + "6": "[6] 1/3 oktáv / 30 sáv", + "7": "[7] Fél oktáv / 20 sáv", + "8": "[8] Teljes oktáv / 10 sáv", + "10": "[10] Vonal / Területdiagram" + } + }, + "showFPS": "Mutat FPS" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index adba81d12..6943c36f4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -440,7 +440,30 @@ "autoDJ_albumStrategy": "アルバム選択モード", "autoDJ_songStrategy": "選曲モード", "autoDJ_strategy_option_library_random": "ランダム", - "autoDJ_strategy_option_similar": "類似" + "autoDJ_strategy_option_similar": "類似", + "enableFurigana_description": "日本語の漢字歌詞の上にふりがなを表示します。", + "enableFurigana": "ふりがなの表示を有効にする", + "enableRomaji_description": "日本語の歌詞の下にローマ字の発音表記を表示します。", + "enableRomaji": "ローマ字の表示を有効にする", + "equalizer": "イコライザー", + "equalizerBands_description": "バンドごとのゲイン。上下にドラッグするか、値を入力してください。範囲: -12 ~ +12 dB。", + "equalizerBands": "バンド", + "equalizerPreset": "プリセット", + "equalizerPresetGroupBuiltIn": "内蔵", + "equalizerPresetGroupCustom": "カスタム", + "equalizerPresetSelectPlaceholder": "プリセットを選択", + "equalizerSavePreset_description": "現在のイコライザー設定を名前付きプリセットとして保存します", + "equalizerSavePreset": "プリセットを保存", + "equalizerPreset_description": "内蔵または保存済みのカスタムイコライザーカーブを適用します", + "compressor": "コンプレッサー", + "compressorAttack_description": "信号が閾値を超えた後、コンプレッサーが作動するまでの時間を設定します。", + "sidebarPlaylistFolders": "フォルダーを有効化", + "sidebarPlaylistFolderSeparator_description": "プレイリスト名でフォルダー階層を区切る文字(または文字列)を設定します", + "sidebarPlaylistFolderSeparator": "フォルダーセパレーター", + "sidebarPlaylistFolders_description": "設定済みの区切り文字が名前に含まれるプレイリストのフォルダービューを作成します", + "sidebarPlaylistFolderTreeIndent": "ツリーのインデント", + "sidebarPlaylistMode_optionCompact": "コンパクト", + "sidebarPlaylistMode_optionExpanded": "拡張" }, "action": { "editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集", @@ -1213,7 +1236,9 @@ "isGreaterThan": "より大きい", "isLessThan": "より小さい", "notInPlaylist": "いずれでもない", - "notInTheLast": "より前" + "notInTheLast": "より前", + "isMissing": "欠落している", + "isPresent": "存在する" }, "visualizer": { "visualizerType": "ビジュアライザーの種類", diff --git a/src/i18n/locales/nn.json b/src/i18n/locales/nn.json new file mode 100644 index 000000000..e0bffe3af --- /dev/null +++ b/src/i18n/locales/nn.json @@ -0,0 +1,235 @@ +{ + "action": { + "addToFavorites": "Legg til i $t(entity.favorite, {\"count\": 2})", + "addToPlaylist": "Legg til i $t(entity.playlist, {\"count\": 1})", + "addOrRemoveFromSelection": "Legg til eller fjern fra val", + "selectRangeOfItems": "Vel eit utval av element", + "clearQueue": "Tøm kø", + "goToCurrent": "Gå til noverande element", + "collapseAllFolders": "Lukk alle mapper", + "expandAllFolders": "Opne alle mapper", + "createPlaylist": "Lag $t(entity.playlist, {\"count\": 1})", + "createRadioStation": "Lag $t(entity.radioStation, {\"count\": 1})", + "deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})", + "deleteRadioStation": "Slett $t(entity.radioStation, {\"count\": 1})", + "selectAll": "Vel alle", + "deselectAll": "Opphev alle val", + "downloadStarted": "Starta nedlasting av {{count}} element", + "editPlaylist": "Rediger $t(entity.playlist, {\"count\": 1})", + "goToPage": "Gå til side", + "moveToNext": "Flytt til neste", + "moveToBottom": "Flytt til botnen", + "moveToTop": "Flytt til toppen", + "moveUp": "Flytt opp", + "moveDown": "Flytt ned", + "holdToMoveToTop": "Held inne for å flytte til toppen", + "holdToMoveToBottom": "Held inne for å flytte til botnen", + "moveItems": "Flytt element", + "shuffle": "Stokking", + "shuffleAll": "Stokk alle", + "shuffleSelected": "Stokk valde", + "refresh": "$t(common.refresh)", + "removeFromFavorites": "Fjern frå $t(entity.favorite, {\"count\": 2})", + "removeFromPlaylist": "Fjern frå $t(entity.playlist, {\"count\": 1})", + "removeFromQueue": "Fjern frå kø", + "setRating": "Set vurdering", + "toggleSmartPlaylistEditor": "Slå $t(entity.smartPlaylist) editor av/på", + "viewPlaylists": "Vis $t(entity.playlist, {\"count\": 2})", + "viewMore": "Vis fleire", + "openApplicationDirectory": "Opne applikasjonsmappa", + "openIn": { + "lastfm": "Opne i Last.fm", + "listenbrainz": "Opne i ListenBrainz", + "musicbrainz": "Opne i MusicBrainz", + "qobuz": "Opne i Qobuz", + "spotify": "Opne i Spotify" + } + }, + "common": { + "countSelected": "{{count}} valt", + "explicitStatus": "Eksplisittstatus", + "action_one": "Handling", + "action_other": "Handlingar", + "add": "Legg til", + "additionalParticipants": "Ytterlegare deltakarar", + "newVersion": "Ein ny versjon vart installert ({{version}})", + "viewReleaseNotes": "Sjå utgivelsesnotata", + "albumGain": "Albumforsterkning", + "albumPeak": "Albumtopp", + "areYouSure": "Er du sikker?", + "ascending": "Stigande", + "back": "Tilbake", + "backward": "Bakover", + "biography": "Biografi", + "bitDepth": "Bit-dybde", + "bitrate": "Bitrate", + "bpm": "BPM", + "cancel": "Avbryt", + "center": "Sentrer", + "channel_one": "Kanal", + "channel_other": "Kanalar", + "clear": "Tøm", + "close": "Lukk", + "codec": "Codec", + "collapse": "Lukk", + "comingSoon": "Kjem snart…", + "configure": "Konfigurer", + "confirm": "Bekreft", + "create": "Lag", + "currentSong": "Noverande $t(entity.track, {\"count\": 1})", + "decrease": "Reduser", + "delete": "Slett", + "descending": "Søkkande", + "description": "Beskriving", + "disable": "Skru av", + "disc": "Disk", + "dismiss": "Avvis", + "doNotShowAgain": "Ikkje vis dette igjen", + "duration": "Varigheit", + "view": "Vis", + "edit": "Rediger", + "enable": "Skru på", + "expand": "Utvid", + "example": "Døme", + "externalLinks": "Eksterne lenker", + "openFolder": "Open mappe", + "faster": "Raskare", + "favorite": "Favoriser", + "filter_one": "Filter", + "filter_other": "Filter", + "filters": "Filter", + "filter_single": "Single", + "filter_multiple": "Multi", + "forceRestartRequired": "Start på nytt for at endringane vert teke i bruk… lukk varslina for å starte på nytt", + "forward": "Framover", + "gap": "Mellomrom", + "home": "Heim", + "increase": "Auk", + "left": "Venstre", + "limit": "Grense", + "manage": "Administrer", + "maximize": "Maksimer", + "menu": "Meny", + "minimize": "Minimer", + "modified": "Modifisert", + "mbid": "MusicBrainz ID", + "grouping": "Gruppering", + "mood": "Humør", + "name": "Namn", + "no": "Nei", + "none": "Ingen", + "noResultsFromQuery": "Søket gav ingen resultat", + "numberOfResults": "{{numberOfResults}} resultat", + "noFilters": "Ingen filter konfigurert", + "note": "Notat", + "ok": "Ok", + "owner": "Eigar", + "path": "Bane", + "playerMustBePaused": "Spelaren må vere på pause", + "preview": "Førehandsvisning", + "previousSong": "Forrige $t(entity.track, {\"count\": 1})", + "private": "Privat", + "public": "Offentleg", + "quit": "Lukk", + "random": "Tilfeldig", + "rating": "Vurdering", + "retry": "Prøv på nytt", + "recordLabel": "Plateselskap", + "releaseType": "Utgjevingstype", + "refresh": "Oppdater", + "reload": "Oppdater", + "rename": "Gje nytt namn", + "reset": "Tilbakestill", + "resetToDefault": "Tilbakestill til standard", + "restartRequired": "Omstart nødvendig", + "right": "Høgre", + "sampleRate": "Samplingsfrekvens", + "save": "Lagre", + "saveAndReplace": "Lagre og erstatt", + "saveAs": "Lagre som", + "search": "Søk", + "setting_one": "Instilling", + "setting_other": "Instillingar", + "slower": "Saktare", + "share": "Del", + "size": "Storleik", + "sort": "Sorter", + "sortOrder": "Rekkjefølgje", + "tags": "Taggar", + "title": "Tittel", + "trackNumber": "Spor", + "trackGain": "Sporforsterkning", + "trackPeak": "Sporetopp", + "translation": "Omsetjing", + "unknown": "Ukjend", + "version": "Versjon", + "year": "År", + "yes": "Ja", + "explicit": "Eksplisitt", + "clean": "Rein", + "gridRows": "Rutenettrader", + "tableColumns": "Tabellkolonnar", + "itemsMore": "{{count}} fleire", + "newVersionAvailable": "Ein ny versjon er tilgjengeleg" + }, + "entity": { + "album_one": "Album", + "album_other": "Album", + "albumArtist_one": "Albumartist", + "albumArtist_other": "Albumartistar", + "albumArtistCount_one": "{{count}} albumartist", + "albumArtistCount_other": "{{count}} albumartistar", + "albumWithCount_one": "{{count}} album", + "albumWithCount_other": "{{count}} album", + "radioStation_one": "Radiostasjon", + "radioStation_other": "Radiostasjonar", + "radioStationWithCount_one": "{{count}} radiostasjon", + "radioStationWithCount_other": "{{count}} radiostasjonar", + "artist_one": "Artist", + "artist_other": "Artistar", + "artistWithCount_one": "{{count}} artist", + "artistWithCount_other": "{{count}} artistar", + "favorite_one": "Favoritt", + "favorite_other": "Favorittar", + "folder_one": "Mappe", + "folder_other": "Mapper", + "folderWithCount_one": "{{count}} mappe", + "folderWithCount_other": "{{count}} mapper", + "genre_one": "Sjanger", + "genre_other": "Sjangrar", + "genreWithCount_one": "{{count}} sjanger", + "genreWithCount_other": "{{count}} sjangrar", + "playlist_one": "Speleliste", + "playlist_other": "Spelelister", + "play_one": "{{count}} avspeling", + "play_other": "{{count}} avspelingar", + "playlistWithCount_one": "{{count}} speleliste", + "playlistWithCount_other": "{{count}} spelelister", + "smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})", + "track_one": "Spor", + "track_other": "Spor", + "song_one": "Song", + "song_other": "Songar", + "trackWithCount_one": "{{count}} spor", + "trackWithCount_other": "{{count}} spor" + }, + "error": { + "apiRouteError": "Kunne ikkje rute førespurnaden", + "audioDeviceFetchError": "Det oppstod ein feil under forsøk på å hente lydeiningar", + "authenticationFailed": "Autentisering feila", + "badAlbum": "Du ser denne sida fordi denne songen ikkje er ein del av eit album. Mest sannsynleg ser du dette fordi du har songen på toppen av musikkmappa di. Jellyfin grupperar berre spor viss dei er i ei mappe", + "badValue": "Ugyldig val \"{{value}}\". Denne verdien finst ikkje lenger", + "credentialsRequired": "Legitimasjon krevjast", + "endpointNotImplementedError": "Endepunktet {{endpoint}} er ikkje implementert for {{serverType}}", + "genericError": "Ein feil skjedde", + "invalidJson": "Ugyldig JSON", + "invalidServer": "Ugyldig sørvar", + "localFontAccessDenied": "Ingen tilgang til lokale skrifttypar", + "loginRateError": "For mange innloggingsforsøk, venlegast prøv på nytt om nokon sekundar", + "mpvRequired": "MPV nødvendig", + "multipleServerSaveQueueError": "Spelekøen har ein eller fleire songar som ikkje er frå den noverande sørvaren. Dette er ikkje støtta", + "networkError": "Ein nettverksfeil skjedde", + "noNetwork": "Sørvar utilgjengeleg", + "noNetworkDescription": "Kunne ikkje kople til denne sørvaren" + } +} diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 188b807e2..a09c23821 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -348,7 +348,8 @@ "input_preferInstantMixDescription": "Używaj tylko natychmiastowego mixu, by otrzymać podobne piosenki. przydatne gdy masz wtyczki które zmieniają to zachowanie", "input_preferRemoteUrl": "Preferuj publiczny url", "input_remoteUrl": "Publiczny url", - "input_remoteUrlPlaceholder": "Opcjonalne: publiczny url dla funkcji zewnętrznych" + "input_remoteUrlPlaceholder": "Opcjonalne: publiczny url dla funkcji zewnętrznych", + "input_passwordNoSSO": "Autoryzacja przez reverse proxy (SSO) nie jest obsługiwana. Tylko autoryzacja nazwą użytkownika/hasłem i autoryzacja Subsonic są obsługiwane" }, "addToPlaylist": { "success": "Dodano $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", @@ -454,13 +455,21 @@ "dynamicIsImage": "Włącz obraz w tle", "lyricOffset": "Opóźnienie tekstów (ms)", "lyricOpacityNonActive": "Przezroczystość nieaktywnego tekstu", - "lyricScaleNonActive": "Skala nieaktywnego tekstu" + "lyricScaleNonActive": "Skala nieaktywnego tekstu", + "lyricPaddingLeft": "Odstęp tekstu z lewej (%)", + "lyricPaddingRight": "Odstęp tekstu z prawej (%)", + "lyricFollowScrollAlignment": "Wyrównywanie tekstu", + "lyricLineLeadTime": "Czas przed linijką (ms)" }, "upNext": "Następne", "lyrics": "Tekst", "related": "Powiązane", "visualizer": "Wizualizer", - "noLyrics": "Nie znaleziono tekstu" + "noLyrics": "Nie znaleziono tekstu", + "lyricLayers": "Warstwy tekstu", + "lyricLanguage": "Język", + "showPronunciation": "Wymowa", + "showTranslation": "Tłumaczenie" }, "appMenu": { "selectServer": "Wybierz serwer", @@ -1108,7 +1117,50 @@ "autoDJ_albumStrategy": "Tryb wyboru albumów", "autoDJ_songStrategy": "Tryb wyboru piosenek", "autoDJ_strategy_option_library_random": "Losowo", - "autoDJ_strategy_option_similar": "Podobne" + "autoDJ_strategy_option_similar": "Podobne", + "enableFurigana_description": "Wyświetlaj pomoce wymowy (furigana) nad tekstami Japońskimi kanji.", + "enableFurigana": "Włącz generowanie furigana", + "equalizer_descriptionMpv": "Equalizer parametryczny przez FFmpeg lavfi (MPV)", + "equalizer_descriptionWebAudio": "Parametryczny equalizer przez API Web Audio", + "equalizer": "Equalizer", + "equalizerBands_description": "Wzmocnienie dla poszczególnych pasm. Przesuń w górę/dół lub wpisz wartość. Zakres: -12 do +12 dB.", + "equalizerBands": "Pasma", + "equalizerPreamp_description": "Wzmocnienie sygnału przed pasmami EQ. Ustaw na wartość ujemną podczas wzmacniania pasm, aby zapobiec przesterowaniu (MPV).", + "equalizerPreamp": "Przedwzmacnianie", + "equalizerPreset_description": "Zastosuj wbudowaną lub niestandardową zapisaną krzywą EQ", + "equalizerPreset": "Ustawienia wstępne", + "equalizerPresetDeletePlaceholder": "Usuń niestandardowe...", + "equalizerPresetGroupBuiltIn": "Wbudowane", + "equalizerPresetGroupCustom": "Niestandardowe", + "equalizerPresetNamePlaceholder": "Nazwa ustawień wstępnych...", + "equalizerPresetSelectPlaceholder": "Wybierz ustawienia wstępne", + "equalizerSavePreset_description": "Zapisz aktualne ustawienia EQ jako nazwany zestaw ustawień wstępnych", + "equalizerSavePreset": "Zapisz ustawienia wstępne", + "compressor_descriptionMpv": "Kompresor zakresu dynamicznego przez FFmpeg acompressor (MPV)", + "compressor_descriptionWebAudio": "Kompresor zakresu dynamicznego poprzez API Web Audio", + "compressor": "Kompresor", + "compressorAttack_description": "Jak szybko załączany jest kompresor po przekroczeniu progu przez sygnał.", + "compressorAttack": "Attack", + "compressorKnee_description": "Szerokośc soft-knee. Większe wartości powodują przejście do kompresji bardziej stopniowym.", + "compressorKnee": "Knee", + "compressorMakeupGain_description": "Zwiększenie wyjściowe dodawane po kompresji aby, przywrócić głośność.", + "compressorMakeupGain": "Makeup Gain", + "compressorPreset_description": "Zastosuj wbudowane lub niestandardowe zapisane ustawienie kompresora", + "compressorRatio_description": "Proporcje kompresji, np. 4 = 4:1.", + "compressorRatio": "Proporcje", + "compressorRelease_description": "Jak szybko kompresor odpuszcza po spadnięciu sygnału poniżej progu.", + "compressorRelease": "Odpuszczenie", + "compressorReset_description": "Przywróć wszystkie parametry kompresora do wartości domyślnych", + "compressorSavePreset_description": "Zapisz aktualne ustawienia kompresora jako nazwany zestaw ustawień wstępnych", + "compressorThreshold_description": "Poziom sygnału nad którym rozpoczyna się kompresja.", + "compressorThreshold": "Próg", + "enableRomaji_description": "Wyświetlaj linijkę z wymową romaji pod japońskim tekstem.", + "enableRomaji": "Włącz generowanie romaji", + "windowBarTrackinfo": "Informacje o utworze w tytule okna", + "windowBarTrackinfo_description": "Wyświetlaj tytuł, wykonawcę, pozycję w kolejce i stan Odtwarzania/Wstrzymania w tytule okna.", + "audioDeviceDefault": "Domyślne systemu", + "microtonalPitchControls": "Mikrotonowe sterowanie wysokością dźwięku", + "microtonalPitchControls_description": "Dodaj więcej sterowania wysokością dźwięku pomiędzy półtonami" }, "table": { "config": { @@ -1148,7 +1200,11 @@ "horizontalBorders": "Obwódki wierszy", "rowHoverHighlight": "Podświetlanie wierszy po najechaniu", "verticalBorders": "Obwódki kolumn", - "showHeader": "Pokaż nagłówek" + "showHeader": "Pokaż nagłówek", + "albumImageSize": "Rozmiar obrazów albumów", + "albumGroupConfig": "Konfiguracja grupy albumów", + "albumGroupMetadata": "Pola metadanych", + "albumGroupShowFavoriteRating": "Pokaż ulubione i oceny" }, "label": { "releaseDate": "Data premiery", @@ -1270,7 +1326,9 @@ "notContains": "Nie zawiera", "notInPlaylist": "Nie jest w", "notInTheLast": "Nie jest w ostatnim", - "startsWith": "Zaczyna się od" + "startsWith": "Zaczyna się od", + "isMissing": "brakuje", + "isPresent": "jest" }, "datetime": { "minuteShort": "Min", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 9fece3aaf..cf762c297 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -15,8 +15,8 @@ "edit": "Editar", "favorite": "Favorito", "currentSong": "$t(entity.track, {\"count\": 1}) atual", - "descending": "Abaixar", - "dismiss": "Liberar", + "descending": "Decrescente", + "dismiss": "Ignorar", "duration": "Duração", "decrease": "Diminuir", "description": "Descrição", @@ -24,7 +24,7 @@ "enable": "Habilitar", "clear": "Limpar", "delete": "Deletar", - "title": "Titulo", + "title": "Título", "create": "Criar", "confirm": "Confirmar", "home": "Início", @@ -59,8 +59,8 @@ "forward": "Para frente", "forceRestartRequired": "Reinicie para aplicar as alterações… feche a notificação para reiniciar", "setting_one": "Configuração", - "setting_many": "", - "setting_other": "", + "setting_many": "Configurações", + "setting_other": "Configurações", "version": "Versão", "filter_one": "Filtro", "filter_many": "filtros", @@ -69,15 +69,15 @@ "saveAndReplace": "Salvar e substituir", "playerMustBePaused": "O player deve estar pausado", "resetToDefault": "Restaurar ao padrão", - "reset": "Reiniciar", + "reset": "Resetar", "sortOrder": "Ordem", "none": "Nenhum", "menu": "Menu", "restartRequired": "É necessário reiniciar", - "previousSong": "Anterior $t(entity.track, {\"count\": 1})", - "noResultsFromQuery": "A consulta não retornou resultados", + "previousSong": "$t(entity.track, {\"count\": 1}) anterior", + "noResultsFromQuery": "A pesquisa não retornou resultados", "quit": "Sair", - "search": "Procurar", + "search": "Pesquisar", "saveAs": "Salvar como", "yes": "Sim", "random": "Aleatório", @@ -100,7 +100,35 @@ "newVersion": "Uma nova versão foi instalada ({{version}})", "viewReleaseNotes": "Ver notas de lançamento", "bitDepth": "Profundidade de bits", - "sampleRate": "Taxa de amostragem" + "sampleRate": "Taxa de amostragem", + "countSelected": "{{count}} selecionadas", + "explicitStatus": "Explicito", + "back": "Voltar", + "doNotShowAgain": "Não mostrar novamente", + "view": "Ver", + "example": "Exemplo", + "externalLinks": "Links externos", + "openFolder": "Abrir pasta", + "faster": "Acelerar", + "filter_single": "Single", + "filter_multiple": "Multi", + "mood": "Clima", + "numberOfResults": "{{numberOfResults}} resultados", + "noFilters": "Nenhum filtro configurado", + "private": "Privado", + "public": "Público", + "retry": "Tentar novamente", + "recordLabel": "Gravadora", + "releaseType": "Tipo de lançamento", + "rename": "Renomear", + "slower": "Mais lento", + "sort": "Ordenar", + "explicit": "Explícito", + "clean": "Limpo", + "gridRows": "Linhas da grade", + "tableColumns": "Colunas da tabela", + "itemsMore": "Mais {{count}}", + "newVersionAvailable": "Uma nova versão está disponível" }, "action": { "goToPage": "Vá para página", @@ -123,7 +151,8 @@ "lastfm": "Abrir em Last.fm", "musicbrainz": "Abrir em MusicBrainz", "listenbrainz": "Abrir no ListenBrainz", - "qobuz": "Abrir no Qobuz" + "qobuz": "Abrir no Qobuz", + "spotify": "Abrir no Spotify" }, "toggleSmartPlaylistEditor": "Alternar editor $t(entity.smartPlaylist)", "moveToNext": "Mover para o próximo", @@ -135,7 +164,17 @@ "holdToMoveToTop": "Segure para ir ao topo", "moveItems": "Mover itens", "viewMore": "Ver mais", - "openApplicationDirectory": "Abrir a pasta do aplicativo" + "openApplicationDirectory": "Abrir a pasta do aplicativo", + "selectRangeOfItems": "Selecionar uma lista de itens", + "collapseAllFolders": "Minimizar todas as pastas", + "downloadStarted": "Download de {{count}} items iniciado", + "moveUp": "Mover a cima", + "moveDown": "Mover a baixo", + "holdToMoveToBottom": "Segure para mover ao final", + "shuffle": "Shuffle", + "shuffleAll": "Shuffle todas", + "shuffleSelected": "Shuffle selecionadas", + "expandAllFolders": "Expandir todas as pastas" }, "form": { "deletePlaylist": { @@ -156,7 +195,10 @@ "input_username": "Nome de usuário", "ignoreCors": "Ignorar CORS ($t(common.restartRequired))", "input_preferInstantMix": "Preferir mixagem instantânea", - "input_preferInstantMixDescription": "Usar apenas a mixagem instantânea para obter músicas semelhantes. Útil se você tiver plugins que modificam esse comportamento" + "input_preferInstantMixDescription": "Usar apenas a mixagem instantânea para obter músicas semelhantes. Útil se você tiver plugins que modificam esse comportamento", + "input_preferRemoteUrl": "Preferir URL pública", + "input_remoteUrl": "URL pública", + "input_remoteUrlPlaceholder": "Opcional: URL pública para funções externas" }, "createPlaylist": { "title": "Criar $t(entity.playlist, {\"count\": 1})", @@ -179,7 +221,10 @@ "title": "Adicionar à $t(entity.playlist, {\"count\": 1})", "input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_skipDuplicates": "Pular duplicadas", - "success": "Adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })" + "success": "Adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", + "create": "Criar $t(entity.playlist, {\"count\": 1}) {{playlist}}", + "noneAdded": "Nenhuma faixa foi adicionada à $t(entity.playlist, {\"count\": 1}) {{playlist}}", + "searchOrCreate": "Pesquisar $t(entity.playlist, {\"count\": 2}) ou criar uma nova" }, "lyricSearch": { "title": "Pesquisa de letras", @@ -203,6 +248,24 @@ "enabled": "Modo privado ativado, o status de reprodução agora está oculto para integrações externas", "disabled": "Modo privado desativado, o status de reprodução agora está visível para as integrações externas ativadas", "title": "Modo privado" + }, + "largeFetchConfirmation": { + "title": "Adicionar items à lista de reprodução", + "description": "Essa ação adicionará todos os itens do filtro atual" + }, + "createRadioStation": { + "success": "Estação de rádio criada com sucesso", + "title": "Criar estação de rádio", + "input_homepageUrl": "URL da página principal", + "input_name": "Nome", + "input_streamUrl": "URL da stream" + }, + "editRadioStation": { + "success": "Estação de rádio atualizada com sucesso" + }, + "lyricsExport": { + "export": "Exportar letra", + "input_synced": "Exportar letra sincronizada" } }, "setting": { @@ -536,7 +599,8 @@ "title": "$t(common.home)", "explore": "Explore a sua biblioteca", "recentlyPlayed": "Tocado recentemente", - "recentlyReleased": "Lançamentos recentes" + "recentlyReleased": "Lançamentos recentes", + "genres": "$t(entity.genre, {\"count\": 2})" }, "albumArtistList": { "title": "$t(entity.albumArtist, {\"count\": 2})" @@ -572,7 +636,8 @@ "playlists": "$t(entity.playlist, {\"count\": 2})", "search": "$t(common.search)", "settings": "$t(common.setting, {\"count\": 2})", - "myLibrary": "Minha biblioteca" + "myLibrary": "Minha biblioteca", + "collections": "Coleções" }, "playlistList": { "title": "$t(entity.playlist, {\"count\": 2})" @@ -594,7 +659,8 @@ "manageServers": "Gerenciar servidores", "settings": "$t(common.setting, {\"count\": 2})", "privateModeOff": "Desativar modo privado", - "privateModeOn": "Ativar modo privado" + "privateModeOn": "Ativar modo privado", + "multipleMusicFolders": "{{count}} pasta(s) de música selecionadas" }, "contextMenu": { "moveToTop": "$t(action.moveToTop)", @@ -620,7 +686,9 @@ "removeFromPlaylist": "$t(action.removeFromPlaylist)", "setRating": "$t(action.setRating)", "goToAlbum": "Ir para $t(entity.album, {\"count\": 1})", - "goToAlbumArtist": "Ir para $t(entity.albumArtist, {\"count\": 1})" + "goToAlbumArtist": "Ir para $t(entity.albumArtist, {\"count\": 1})", + "moveItems": "$t(action.moveItems)", + "goTo": "Ir para" }, "albumArtistDetail": { "viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})", @@ -648,7 +716,9 @@ "lyricGap": "Espaçamento da letra", "lyricSize": "Tamanho da letra", "dynamicBackground": "Fundo dinâmico", - "opacity": "Opacidade" + "opacity": "Opacidade", + "lyricOpacityNonActive": "Opacidade da letra inativa", + "lyricScaleNonActive": "Escala da letra inativa" }, "related": "Relacionado", "visualizer": "Visualizador", @@ -679,19 +749,44 @@ "hotkeysTab": "Teclas de atalho", "windowTab": "Janela", "advanced": "Avançado", - "playbackTab": "Reprodução" + "playbackTab": "Reprodução", + "analytics": "Analíticas", + "updates": "Atualização", + "cache": "Cache", + "application": "Aplicação", + "queryBuilder": "Construtor de pesquisa", + "theme": "Tema", + "controls": "Controles", + "sidebar": "Barra lateral", + "remote": "Controle Remoto", + "exportImport": "Importar/exportar", + "scrobble": "Scrobble", + "audio": "Áudio", + "lyrics": "Letra", + "lyricsDisplay": "Visualizar letra", + "transcoding": "Transcoding", + "discord": "Discord", + "logger": "Logger", + "playerFilters": "Filtros de reprodução" }, "playlist": { "reorder": "Reordenar apenas disponível quando ordenado pelo ID" + }, + "folderList": { + "title": "$t(entity.folder, {\"count\": 2})" + }, + "collections": { + "overrideExisting": "Substituir atual", + "saveAsCollection": "Salvar como coleção" } }, "filter": { "title": "Titulo", "disc": "Disco", "mostPlayed": "Mais tocado", - "album": "$t(entity.album, {\"count\": 1})", + "album": "$t({\"count\": 1}, entity.album)", "name": "Nome", - "biography": "Bibliografia", + "biography": "Biografia", "duration": "Duração", "favorited": "Favoritado", "fromYear": "A partir do ano", @@ -717,7 +812,7 @@ "releaseYear": "Ano de lançamento", "rating": "Avaliação", "artist": "$t(entity.artist, {\"count\": 1})", - "bpm": "Bpm", + "bpm": "BPM", "channels": "$t(common.channel_other)", "comment": "Comentário", "owner": "$t(common.owner)", @@ -727,7 +822,11 @@ "isRated": "Possui avaliação", "note": "Nota", "albumCount": "Número de $t(entity.album, {\"count\": 2})", - "genre": "$t(entity.genre, {\"count\": 1})" + "genre": "$t(entity.genre, {\"count\": 1})", + "matchAnd": "E", + "matchOr": "Ou", + "sortName": "Ordenar por nome", + "explicitStatus": "$t(common.explicitStatus)" }, "player": { "playbackFetchNoResults": "Nenhuma música encontrada", @@ -814,7 +913,13 @@ "song_other": "Músicas", "play_one": "{{count}} reprodução", "play_many": "{{count}} reproduções", - "play_other": "{{count}} reproduções" + "play_other": "{{count}} reproduções", + "radioStation_one": "Estação de rádio", + "radioStation_many": "Estações de rádio", + "radioStation_other": "Estações de rádio", + "radioStationWithCount_one": "{{count}} estação de rádio", + "radioStationWithCount_many": "{{count}} estações de rádio", + "radioStationWithCount_other": "{{count}} estações de rádio" }, "error": { "remotePortWarning": "Reinicie o servidor para aplicar a nova porta", @@ -829,17 +934,48 @@ "credentialsRequired": "Credenciais necessárias", "sessionExpiredError": "Sua sessão expirou", "remoteEnableError": "Ocorreu um erro ao tentar $t(common.enable) o servidor remoto", - "localFontAccessDenied": "Acesso negado a fontes locais", + "localFontAccessDenied": "Acesso negado às fontes locais", "serverNotSelectedError": "Nenhum servidor selecionado", "remoteDisableError": "Ocorreu um erro ao tentar $t(common.disable) o servidor remoto", "mpvRequired": "Requer MPV", "audioDeviceFetchError": "Ocorreu um erro ao tentar obter dispositivos de áudio", "invalidServer": "Servidor inválido", "loginRateError": "Muitas tentativas de login, tente novamente em alguns segundos", - "badAlbum": "Você está vendo este erro por que está música não é parte de algum álbum. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta", - "networkError": "Ocorreu um erro na internet", + "badAlbum": "Você está vendo este erro por que está música não é parte de algum álbum. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. O Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta", + "networkError": "Ocorreu um erro de rede", "openError": "Não foi possível abrir o arquivo", - "badValue": "Opção inválida \"{{value}}\". este valor não existe no momento", - "notificationDenied": "As permissões para notificações foram negadas. Esta configuração não tem efeito" + "badValue": "Opção inválida \"{{value}}\". Este valor não existe no momento", + "notificationDenied": "As permissões para notificações foram negadas. Esta configuração não fará efeito", + "invalidJson": "JSON inválido", + "multipleServerSaveQueueError": "A fila de reprodução tem uma ou mais faixas que não são do servidor atual. Isso não é suportado", + "noNetwork": "Servidor indisponível", + "noNetworkDescription": "Não foi possível conectar ao servidor", + "playbackPausedDueToError": "Reprodução foi pausada devido a um erro", + "saveQueueFailed": "Falha ao salvar a fila de reprodução", + "serverLockSingleServer": "Apenas um servidor é permitido quando o servidor estiver trancado", + "settingsSyncError": "Foram encontradas discrepâncias entre as configurações do renderizador e do processo principal. Reinicie a aplicação para aplicar as mudanças" + }, + "filterOperator": { + "after": "É depois", + "afterDate": "Depois de (date)", + "before": "Antes de", + "beforeDate": "Antes de (date)", + "contains": "Contém", + "endsWith": "Termina com", + "inPlaylist": "Está em", + "inTheLast": "Está no último", + "inTheRange": "Está entre", + "inTheRangeDate": "Está entre (date)", + "is": "É", + "isNot": "Não é", + "isMissing": "Está faltando", + "isPresent": "Está presente", + "isGreaterThan": "É maior que", + "isLessThan": "É menor que", + "matchesRegex": "Corresponde ao regex", + "notContains": "Não contém", + "notInPlaylist": "Não se inclui em", + "notInTheLast": "Não está no último", + "startsWith": "Começa com" } } diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index 0967ef424..b31266a32 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1 +1,188 @@ -{} +{ + "action": { + "selectRangeOfItems": "เลือกช่วงของรายการ", + "addToFavorites": "เพิ่มลงใน $t(entity.favorite, {\"count\": 2})", + "addToPlaylist": "เพิ่มลงใน $t(entity.playlist, {\"count\": 1})", + "addOrRemoveFromSelection": "เพิ่มหรือเอาออกจากรายการที่เลือก", + "clearQueue": "ล้างคิว", + "goToCurrent": "ไปยังรายการปัจจุบัน", + "collapseAllFolders": "ยุบโฟลเดอร์ทั้งหมด", + "expandAllFolders": "ขยายโฟลเดอร์ทั้งหมด", + "createPlaylist": "สร้าง $t(entity.playlist, {\"count\": 1})", + "createRadioStation": "สร้าง $t(entity.radioStation, {\"count\": 1})", + "deletePlaylist": "ลบ $t(entity.playlist, {\"count\": 1})", + "deleteRadioStation": "ลบ $t(entity.radioStation, {\"count\": 1})", + "selectAll": "เลือกทั้งหมด", + "deselectAll": "ยกเลิกการเลือกทั้งหมด", + "downloadStarted": "เริ่มดาวน์โหลด {{count}} รายการ", + "editPlaylist": "แก้ไข $t(entity.playlist, {\"count\": 1})", + "goToPage": "ไปที่หน้า", + "moveToNext": "ย้ายไปถัดไป", + "moveToBottom": "ย้ายไปล่างสุด", + "moveToTop": "ย้ายไปบนสุด", + "moveUp": "เลื่อนขึ้น", + "moveDown": "เลื่อนลง", + "holdToMoveToTop": "กดค้างเพื่อย้ายไปบนสุด", + "holdToMoveToBottom": "กดค้างเพื่อย้ายไปล่างสุด", + "moveItems": "ย้ายรายการ", + "shuffle": "สุ่ม", + "shuffleAll": "สุ่มทั้งหมด", + "shuffleSelected": "สุ่มรายการที่เลือก", + "removeFromFavorites": "ลบออกจาก $t(entity.favorite, {\"count\": 2})", + "removeFromPlaylist": "ลบออกจาก $t(entity.playlist, {\"count\": 1})", + "removeFromQueue": "ลบออกจากคิว", + "setRating": "ให้คะแนน", + "toggleSmartPlaylistEditor": "เปิด/ปิดตัวแก้ไข $t(entity.smartPlaylist)", + "viewPlaylists": "ดู $t(entity.playlist, {\"count\": 2})", + "viewMore": "ดูเพิ่มเติม", + "openApplicationDirectory": "เปิดโฟลเดอร์โปรแกรม", + "openIn": { + "lastfm": "เปิดใน Last.fm", + "listenbrainz": "เปิดใน ListenBrainz", + "musicbrainz": "เปิดใน MusicBrainz", + "qobuz": "เปิดใน Qobuz", + "spotify": "เปิดใน Spotify" + } + }, + "common": { + "countSelected": "เลือกแล้ว {{count}} รายการ", + "explicitStatus": "สถานะเนื้อหาชัดเจน", + "action_other": "การดำเนินการ", + "add": "เพิ่ม", + "additionalParticipants": "ผู้เข้าร่วมเพิ่มเติม", + "newVersion": "ติดตั้งเวอร์ชันใหม่แล้ว {{version}}", + "viewReleaseNotes": "ดูบันทึกการเปลี่ยนแปลง", + "albumGain": "ระดับความดังของอัลบั้ม", + "albumPeak": "ระดับเสียงสูงสุดของอัลบั้ม", + "areYouSure": "คุณแน่ใจหรือไม่?", + "ascending": "เรียงจากน้อยไปมาก", + "back": "ย้อนกลับ", + "backward": "ย้อนกลับ", + "biography": "ประวัติ", + "bitDepth": "ความลึกบิต", + "bitrate": "บิตเรต", + "bpm": "จังหวะต่อนาที", + "cancel": "ยกเลิก", + "center": "กึ่งกลาง", + "channel_other": "ช่อง", + "clear": "ล้าง", + "close": "ปิด", + "codec": "ตัวแปลงสัญญาณ", + "collapse": "ยุบ", + "comingSoon": "เร็ว ๆ นี้…", + "configure": "กำหนดค่า", + "confirm": "ยืนยัน", + "create": "สร้าง", + "currentSong": "$t(entity.track, {\"count\": 1}) ปัจจุบัน", + "decrease": "ลด", + "delete": "ลบ", + "descending": "เรียงจากมากไปน้อย", + "description": "คำอธิบาย", + "disable": "ปิดใช้งาน", + "disc": "แผ่น", + "dismiss": "ปิด", + "doNotShowAgain": "ไม่ต้องแสดงอีก", + "duration": "ระยะเวลา", + "view": "ดู", + "edit": "แก้ไข", + "enable": "เปิดใช้งาน", + "expand": "ขยาย", + "example": "ตัวอย่าง", + "externalLinks": "ลิงก์ภายนอก", + "openFolder": "เปิดโฟลเดอร์", + "faster": "เร็วขึ้น", + "favorite": "รายการโปรด", + "filter_other": "ตัวกรอง", + "filters": "ตัวกรอง", + "filter_single": "เดี่ยว", + "filter_multiple": "หลายรายการ", + "forceRestartRequired": "รีสตาร์ตเพื่อใช้การเปลี่ยนแปลง... ปิดการแจ้งเตือนนี้เพื่อรีสตาร์ต", + "forward": "ไปข้างหน้า", + "gap": "ระยะห่าง", + "home": "หน้าแรก", + "increase": "เพิ่ม", + "left": "ซ้าย", + "limit": "จำกัด", + "manage": "จัดการ", + "maximize": "ขยายใหญ่สุด", + "menu": "เมนู", + "minimize": "ย่อหน้าต่าง", + "modified": "แก้ไขล่าสุด", + "grouping": "การจัดกลุ่ม", + "mood": "อารมณ์", + "name": "ชื่อ", + "no": "ไม่", + "none": "ไม่มี", + "noResultsFromQuery": "ไม่พบผลลัพธ์ที่ตรงกับการค้นหา", + "numberOfResults": "พบ {{numberOfResults}} รายการ", + "noFilters": "ยังไม่ได้ตั้งค่าตัวกรอง", + "note": "หมายเหตุ", + "ok": "ตกลง", + "owner": "เจ้าของ", + "path": "เส้นทาง", + "playerMustBePaused": "ต้องหยุดการเล่นก่อน", + "preview": "แสดงตัวอย่าง", + "previousSong": "$t(entity.track, {\"count\": 1}) ก่อนหน้า", + "private": "ส่วนตัว", + "public": "สาธารณะ", + "quit": "ออก", + "random": "สุ่ม", + "rating": "คะแนน", + "retry": "ลองใหม่", + "recordLabel": "ค่ายเพลง", + "releaseType": "ประเภทการเผยแพร่", + "refresh": "รีเฟรชข้อมูล", + "reload": "โหลดใหม่", + "rename": "เปลี่ยนชื่อ", + "reset": "รีเซ็ต", + "resetToDefault": "รีเซ็ตเป็นค่าเริ่มต้น", + "restartRequired": "จำเป็นต้องเริ่มโปรแกรมใหม่", + "right": "ขวา", + "sampleRate": "อัตราการสุ่มตัวอย่าง", + "save": "บันทึก", + "saveAndReplace": "บันทึกและแทนที่", + "saveAs": "บันทึกเป็น...", + "search": "ค้นหา", + "setting_other": "การตั้งค่า", + "slower": "ช้าลง", + "share": "แบ่งปัน", + "size": "ขนาด", + "sort": "เรียงลำดับ", + "sortOrder": "ลำดับ", + "tags": "แท็ก", + "title": "ชื่อเรื่อง", + "trackNumber": "แทร็ก", + "trackGain": "ระดับขยายเสียงแทร็ก", + "trackPeak": "ระดับเสียงสูงสุดของแทร็ก", + "translation": "การแปล", + "unknown": "ไม่ทราบ", + "version": "เวอร์ชัน", + "year": "ปี", + "yes": "ใช่", + "explicit": "เนื้อหาชัดแจ้ง", + "clean": "เวอร์ชันสะอาด", + "gridRows": "แถวของตาราง", + "tableColumns": "คอลัมน์ของตาราง", + "itemsMore": "เพิ่มอีก {{count}}", + "newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งาน" + }, + "entity": { + "album_other": "อัลบั้ม", + "albumArtist_other": "ศิลปินอัลบั้ม", + "albumArtistCount_other": "{{count}} ศิลปินอัลบั้ม", + "albumWithCount_other": "{{count}} อัลบั้ม", + "radioStation_other": "สถานีวิทยุ", + "radioStationWithCount_other": "{{count}} สถานีวิทยุ", + "artist_other": "ศิลปิน", + "artistWithCount_other": "{{count}} ศิลปิน", + "favorite_other": "รายการโปรด", + "folder_other": "โฟลเดอร์", + "folderWithCount_other": "{{count}} โฟลเดอร์", + "genre_other": "แนวเพลง", + "genreWithCount_other": "{{count}} แนวเพลง", + "playlist_other": "เพลย์ลิสต์", + "play_other": "{{count}} เล่น", + "playlistWithCount_other": "{{count}} เพลย์ลิสต์", + "smartPlaylist": "$t(entity.playlist, {\"count\": 1})อัจฉริยะ" + } +} diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index ed9a92a4e..d7959f91c 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -9,7 +9,10 @@ "viewPlaylists": "$t(entity.playlist, {\"count\": 2}) listesini görüntüle", "openIn": { "lastfm": "Last.fm'de aç", - "musicbrainz": "MusicBrainz'da aç" + "musicbrainz": "MusicBrainz'da aç", + "spotify": "Spotify'da aç", + "listenbrainz": "ListenBrainz'de aç", + "qobuz": "Qobuz'da aç" }, "addToFavorites": "$t(entity.favorite, {\"count\": 2}) listesine ekle", "addToPlaylist": "$t(entity.playlist, {\"count\": 1}) listesine ekle", @@ -29,7 +32,17 @@ "selectAll": "Tümünü seç", "downloadStarted": "{{count}} öğenin indirilmesine başlandı", "moveUp": "Yukarı kaydır", - "moveDown": "Aşağı kaydır" + "moveDown": "Aşağı kaydır", + "collapseAllFolders": "Tüm klasörleri kapat", + "expandAllFolders": "Tüm klasörleri genişlet", + "holdToMoveToTop": "En üste taşımak için basılı tut", + "holdToMoveToBottom": "En aşağıya taşımak için basılı tut", + "moveItems": "Öğeleri taşı", + "shuffle": "Karışık çal", + "shuffleAll": "Tümünü karıştır", + "shuffleSelected": "Seçilileri karıştır", + "viewMore": "Daha fazlasını görüntüle", + "openApplicationDirectory": "Uygulama dizinini aç" }, "common": { "action_one": "Eylem", @@ -51,7 +64,7 @@ "clear": "Temizle", "close": "Kapat", "codec": "Codec", - "comingSoon": "ÇOK yakında…", + "comingSoon": "Çok yakında…", "configure": "Yapılandır", "confirm": "Onayla", "create": "Oluştur", @@ -108,8 +121,8 @@ "saveAndReplace": "Kaydet ve değiştir", "saveAs": "Farklı kaydet", "search": "Arama", - "setting_one": "Ayarlar", - "setting_other": "", + "setting_one": "Ayar", + "setting_other": "Ayarlar", "share": "Paylaş", "size": "Boyut", "sortOrder": "Sıralama düzeni", @@ -131,15 +144,38 @@ "private": "Gizli", "clean": "Temiz", "countSelected": "{{count}} adet seçildi", - "public": "Herkese açık" + "public": "Herkese açık", + "doNotShowAgain": "Tekrar gösterme", + "view": "Görüntüle", + "example": "Örnek", + "externalLinks": "Dış bağlantılar", + "openFolder": "Klasörü aç", + "filter_single": "Tekli", + "filter_multiple": "Çoklu", + "numberOfResults": "{{numberOfResults}} sonuç", + "retry": "Tekrar dene", + "releaseType": "Yayın türü", + "rename": "Adını değiştir", + "slower": "Daha yavaş", + "sort": "Sıralama", + "explicit": "Sansürsüz", + "tableColumns": "Tablo sütunları", + "itemsMore": "{{count}} fazla", + "newVersionAvailable": "Yeni bir sürüm mevcut", + "explicitStatus": "Sansür durumu", + "back": "Geri", + "faster": "Daha hızlı", + "grouping": "Gruplandırma", + "mood": "Ruh hali", + "noFilters": "Hiçbir filtre yapılandırılmadı" }, "entity": { "album_one": "Albüm", "album_other": "Albümler", - "albumArtist_one": "Albüm sanatçısı", - "albumArtist_other": "Albüm sanatçıları", + "albumArtist_one": "Albüm Sanatçısı", + "albumArtist_other": "Albüm Sanatçıları", "albumArtistCount_one": "{{count}} albüm sanatçısı", - "albumArtistCount_other": "{{count}} albüm sanatçıları", + "albumArtistCount_other": "{{count}} albüm sanatçısı", "albumWithCount_one": "{{count}} albüm", "albumWithCount_other": "{{count}} albüm", "artist_one": "Sanatçı", @@ -155,10 +191,10 @@ "genre_one": "Tür", "genre_other": "Türler", "genreWithCount_one": "{{count}} tür", - "genreWithCount_other": "{{count}} türler", + "genreWithCount_other": "{{count}} tür", "playlist_one": "Çalma listesi", "playlist_other": "Çalma listeleri", - "play_one": "{{count}} oynat", + "play_one": "{{count}} oynatma", "play_other": "{{count}} oynatma", "playlistWithCount_one": "{{count}} oynatma listesi", "playlistWithCount_other": "{{count}} oynatma listesi", @@ -169,8 +205,10 @@ "song_other": "Şarkılar", "trackWithCount_one": "{{count}} parça", "trackWithCount_other": "{{count}} parça", - "radioStation_one": "Radyo istasyonu", - "radioStation_other": "Radyo istasyonları" + "radioStation_one": "Radyo İstasyonu", + "radioStation_other": "Radyo İstasyonları", + "radioStationWithCount_one": "{{count}} radyo istasyonu", + "radioStationWithCount_other": "{{count}} radyo istasyonu" }, "error": { "apiRouteError": "İstek yönlendirilemiyor", @@ -196,7 +234,11 @@ "playbackError": "Medya oynatmayı çalışırken bir hata meydana geldi", "credentialsRequired": "Ki̇mli̇k bi̇lgi̇leri̇ gerekli", "remoteDisableError": "Uzak sunucuyu $t(common.disable) yapmaya çalışırken bir hata oluştu", - "remoteEnableError": "Uzak sunucuyu $t(common.enable) yapmaya çalışırken bir hata oluştu" + "remoteEnableError": "Uzak sunucuyu $t(common.enable) yapmaya çalışırken bir hata oluştu", + "invalidJson": "Geçersiz JSON", + "noNetwork": "Sunucu erişilemez durumda", + "noNetworkDescription": "Sunucuya bağlanılamadı", + "playbackPausedDueToError": "Bir hata nedeniyle oynatma duraklatıldı" }, "filter": { "albumCount": "$t(entity.album, {\"count\": 2}) sayısı", @@ -218,7 +260,7 @@ "isRated": "Oylandı", "isRecentlyPlayed": "Yakın zamanda çalındı", "lastPlayed": "Son çalınan", - "mostPlayed": "En çOK çalınan", + "mostPlayed": "En çok çalınan", "name": "İsim", "note": "Not", "owner": "$t(common.owner)", @@ -240,7 +282,9 @@ "album": "$t(entity.album, {\"count\": 1})", "albumArtist": "$t(entity.albumArtist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})", - "channels": "$t(common.channel_other)" + "channels": "$t(common.channel, {\"count\": 2})", + "sortName": "Adı sırala", + "explicitStatus": "$t(common.explicitStatus)" }, "form": { "addServer": { @@ -256,13 +300,18 @@ "success": "Sunucu başarıyla eklendi", "title": "Sunucu ekle", "input_preferInstantMix": "Anında mix tercih et", - "input_preferInstantMixDescription": "Sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı" + "input_preferInstantMixDescription": "Sadece benzer şarkılari bulmak icin anında mix kullan. Bu davranışı değiştiren eklentilere sahipseniz faydalı", + "input_preferRemoteUrl": "Herkese Açık URL'yi Tercih Et", + "input_remoteUrl": "Herkese Açık URL", + "input_remoteUrlPlaceholder": "İsteğe bağlı: Dışardan erişim özellikleri için genel URL" }, "addToPlaylist": { "input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_skipDuplicates": "Kopyaları atla", "title": "$t(entity.playlist, {\"count\": 1}) listesine ekle", - "success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) $t(entity.trackWithCount, {\"count\": {{message}} }) eklendi" + "success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) $t(entity.trackWithCount, {\"count\": {{message}} }) eklendi", + "create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} oluştur", + "noneAdded": "$t(entity.playlist, {\"count\": 1}) {{playlist}} çalma listesine parça eklenmedi" }, "createPlaylist": { "input_description": "$t(common.description)", @@ -290,7 +339,10 @@ "queryEditor": { "title": "Sorgu düzenleyici", "input_optionMatchAll": "Hepsini eşleştir", - "input_optionMatchAny": "Herhangi biriyle eşleştir" + "input_optionMatchAny": "Herhangi biriyle eşleştir", + "removeRuleGroup": "Kural grubunu kaldır", + "resetToDefault": "Varsayılan ayarlara dön", + "clearFilters": "Filtreleri temizle" }, "shareItem": { "allowDownloading": "İndirmeye izin ver", @@ -298,7 +350,9 @@ "setExpiration": "Sona erme tarihi ayarla", "success": "Paylaşma bağlantısı panoya kopyalandı (veya açmak için buraya tıklayın)", "expireInvalid": "Son kullanma tarihi gelecekte olmalı", - "createFailed": "Paylaşım oluşturulamadı (paylaşım etkin mi?)" + "createFailed": "Paylaşım oluşturulamadı (paylaşım etkin mi?)", + "copyToClipboard": "Panoya kopyala: Ctrl+C, Enter", + "successMustClick": "Paylaşım başarıyla oluşturuldu. Görüntülemek için tıklayın" }, "updateServer": { "success": "Sunucu başarıyla güncellendi", @@ -308,6 +362,24 @@ "enabled": "Gizli mod etkinleştirildi, oynatma durumu artık harici eklentilerden gizlendi", "disabled": "Gizli mod devre dışı bırakıldı, oynatma durumu artık etkinleştirilmiş harici eklentiler tarafından görülebilir", "title": "Gizli mod" + }, + "largeFetchConfirmation": { + "title": "Öğeleri oynatma kuyruğuna ekle", + "description": "Bu işlem, mevcut filtrelenmiş görünümdeki tüm öğeleri kuyruğa ekler" + }, + "lyricsExport": { + "input_synced": "Senkronize şarkı sözlerini dışa aktar" + }, + "saveQueue": { + "success": "Oynatma kuyruğu sunucuya kaydedildi" + }, + "shuffleAll": { + "title": "Rastgele çal", + "input_kind_albums": "Albümler", + "input_kind_songs": "Şarkılar", + "input_kind": "Karışık seçimler", + "input_limit_albums": "Kaç tane albüm?", + "input_limit_songs": "Kaç tane şarkı?" } }, "page": { @@ -320,7 +392,8 @@ "topSongs": "En iyi şarkılar", "viewAll": "Tümünü görüntüle", "viewAllTracks": "Tüm $t(entity.track, {\"count\": 2}) görüntüle", - "topSongsFrom": "{{title}} tarafından en iyi şarkılar" + "topSongsFrom": "{{title}} tarafından en iyi şarkılar", + "favoriteSongsFrom": "{{title}}’dan favori şarkılar" }, "contextMenu": { "addLast": "$t(player.addLast)", @@ -397,7 +470,8 @@ "mostPlayed": "En çok çalınan", "newlyAdded": "Yeni eklenenler", "recentlyPlayed": "Yakınlarda çalınanlar", - "title": "$t(common.home)" + "title": "$t(common.home)", + "recentlyReleased": "Son çıkanlar" }, "itemDetail": { "copyPath": "Yolu panoya kopyala", @@ -415,7 +489,13 @@ "generalTab": "Genel", "hotkeysTab": "Kısayol tuşları", "playbackTab": "Oynatma", - "windowTab": "Pencere" + "windowTab": "Pencere", + "analytics": "Analitik", + "updates": "Güncelleme", + "cache": "Önbellek", + "application": "Uygulama", + "exportImport": "İçe/dışa aktarma", + "lyrics": "Sözler" }, "sidebar": { "albumArtists": "$t(entity.albumArtist, {\"count\": 2})", @@ -430,7 +510,8 @@ "search": "$t(common.search)", "settings": "$t(common.setting, {\"count\": 2})", "shared": "Paylaşılan $t(entity.playlist, {\"count\": 2})", - "tracks": "$t(entity.track, {\"count\": 2})" + "tracks": "$t(entity.track, {\"count\": 2})", + "collections": "Koleksiyonlar" }, "trackList": { "artistTracks": "{{artist}} parçaları", @@ -463,6 +544,17 @@ "version": "{{version}} sürümü", "privateModeOff": "Gizli modu kapat", "privateModeOn": "Gizli modu aç" + }, + "radioList": { + "title": "Radyo istasyonları" + }, + "releasenotes": { + "commitsSinceStable": "{{stable}}’dan sonraki commit’ler", + "noNewCommits": "Bu aralıkta yeni commit yok", + "noStableReleaseToCompare": "Karşılaştırılacak stabil sürüm bulunamadı" + }, + "favorites": { + "title": "$t(entity.favorite, {\"count\": 2})" } }, "player": { @@ -496,7 +588,15 @@ "toggleFullscreenPlayer": "Tam ekran oynatıcıya geç", "unfavorite": "Favoriden kaldır", "pause": "Durdur", - "viewQueue": "Kuyruğu görüntüle" + "viewQueue": "Kuyruğu görüntüle", + "albumRadio": "Aldüm radyosu", + "artistRadio": "Sanatçı radyosu", + "holdToShuffle": "Karıştırmak için basılı tut", + "lyrics": "Sözler", + "sleepTimer_endOfAlbum": "Mevcut albümün sonu", + "sleepTimer_minutes": "{{count}} dakika", + "sleepTimer_hours": "{{count}} saat", + "sleepTimer_cancel": "Zamanlayıcıyı iptal et" }, "setting": { "accentColor": "Vurgu rengi", @@ -545,7 +645,7 @@ "discordListening_description": "Durumu çalma yerine dinleme olarak göster", "discordRichPresence_description": "{{discord}} \"Rich Presence\" oynatma durumunu etkinleştirin. Görüntü tuşları şunlardır: {{icon}}, {{playing}} ve {{paused}}", "discordServeImage": "Sunucudan {{discord}} resimleri servis et", - "discordServeImage_description": "Sunucudan {{discord}} rich presence için kapak resmi paylaşın, yalnızca Jellyfin ve Navidrome için kullanılabilir", + "discordServeImage_description": "{{discord}} zengin durum bilgisi için kapak görselini sunucudan paylaşır. Yalnızca Jellyfin ve Navidrome için kullanılabilir. {{discord}}, görselleri almak için bir bot kullanır, bu nedenle sunucunuzun internet üzerinden erişilebilir olması gerekir", "discordUpdateInterval": "{{discord}} rich presence güncelleme aralığı", "discordUpdateInterval_description": "Her güncelleme arasındaki saniye cinsinden süre (minimum 15 saniye)", "gaplessAudio": "Aralıksız ses", @@ -733,31 +833,87 @@ "releaseChannel_optionBeta": "Beta", "releaseChannel_optionLatest": "En son", "language": "Dil", - "notify": "Müzik bildirimi aktivleştir" + "notify": "Müzik bildirimi aktivleştir", + "autoDJ": "Otomatik DJ", + "autoDJ_itemCount": "Öğe sayısı", + "autoDJ_itemCount_description": "Kuyruğa eklenmeye çalışılan öğe sayısı", + "autoDJ_timing": "Zamanlama", + "autoDJ_timing_description": "Otomatik DJ başlamadan önce kuyrukta kalan şarkı sayısı", + "autoDJ_songStrategy": "Şarkı seçim modu", + "autoDJ_strategy_option_library_random": "Rastgele", + "autoDJ_strategy_option_similar": "Benzer", + "autosave": "Oynatma kuyruğunu otomatik kaydet", + "autosave_description": "Oynatma kuyruğunu sunucunuza otomatik olarak kaydetmeyi etkinleştirin. Bu özellik yalnızca Navidrome/Subsonic kullanırken mümkündür ve karışık bir oynatma kuyruğu kullanamazsınız.", + "autosaveCount": "Oynatma kuyruğu otomatik kaydetme sıklığı", + "autosaveCount_description": "Kuyruğun kaydedilmesinden önce kaç parça değişikliği yapılmalı. 1 (minimum) her şarkı değişiminde kaydeder", + "useThemeAccentColor": "Tema ana rengini kullan", + "useThemeAccentColor_description": "Özel vurgu rengi yerine seçili temada tanımlı ana rengi kullanın", + "useThemePrimaryShade": "Tema ana renginin tonunu kullan", + "artistReleaseTypeConfiguration_description": "Albüm sanatçısı sayfasında hangi yayın türlerinin gösterileceğini ve hangi sırayla listeleneceğini yapılandırın", + "automaticUpdates_description": "Güncellemeleri otomatik olarak kontrol et ve yükle", + "releaseChannel_description": "Otomatik güncellemeler için stable, beta veya alfa (nightly) sürümler arasından seçim yapın", + "discordLinkType_description": "Şarkı ve sanatçı alanlarına {{discord}} zengin durum bilgisinde {{lastfm}} veya {{musicbrainz}} için harici bağlantılar ekler. {{musicbrainz}} en doğru seçenektir ancak etiketlere ihtiyaç duyar ve sanatçı bağlantısı sağlamaz; buna karşılık {{lastfm}} her zaman bir bağlantı sunar. Ekstra ağ isteği oluşturmaz", + "discordLinkType_mbz_lastfm": "{{musicbrainz}} kullan, {{lastfm}} yedek olarak", + "discordLinkType_none": "$t(common.none)", + "discordLinkType": "{{discord}} zengin durum bağlantıları", + "exportImportSettings_control_exportText": "Ayarları dışa aktar", + "exportImportSettings_control_importText": "Ayarları içe aktar", + "exportImportSettings_control_title": "Ayarları içe / dışa aktar", + "exportImportSettings_destructiveWarning": "Ayarları içe aktarmak geri alınamaz bir işlemdir, lütfen yukarıdakileri gözden geçirin ve ardından aşağıdaki \"İçe Aktar\" butonuna tıklayın!", + "exportImportSettings_importBtn": "Ayarları içe aktar", + "exportImportSettings_importModalTitle": "Feishin ayarlarını içe aktar", + "exportImportSettings_importSuccess": "Ayarlar başarı ile içeri aktarıldı!", + "exportImportSettings_notValidJSON": "Yüklenen dosya geçerli JSON formatında değil", + "listenbrainz": "ListenBrainz bağlantılarını göster", + "logLevel": "Log seviyesi", + "logLevel_description": "Görüntülenecek minimum log seviyesini ayarlar. Debug tüm logları gösterir, error yalnızca hataları gösterir", + "logLevel_optionDebug": "Hata ayıklama", + "logLevel_optionError": "Hata", + "logLevel_optionInfo": "Bilgilendirme", + "logLevel_optionWarn": "Uyarı", + "pathReplace_description": "Sunucunun varsayılan dosya yolunu değiştir", + "pathReplace_optionRemovePrefix": "Öneki kaldır", + "pathReplace_optionAddPrefix": "Ön ek ekle", + "imageResolution": "Resim çözünürlüğü", + "imageResolution_description": "Uygulama genelinde kullanılan görsellerin çözünürlüğü. 0 değeri kullanılırsa varsayılan olarak görselin orijinal çözünürlüğü kullanılır", + "imageResolution_optionTable": "Tablo", + "playerbarWaveformAlign_optionCenter": "Orta", + "showLyricsInSidebar": "Oynatıcı yan panelinde şarkı sözlerini göster", + "blurExplicitImages": "Müstehcen görselleri bulanıklaştır", + "blurExplicitImages_description": "Sansürsüz olarak işaretlenen albüm ve şarkı kapakları bulanıklaştırılır", + "enableGridMultiSelect": "Izgara çoklu seçimi etkinleştir", + "enableGridMultiSelect_description": "Etkinleştirildiğinde, ızgara görünümünde birden fazla öğe seçmeye izin verir. Devre dışı bırakıldığında, ızgara öğelerinin görsellerine tıklamak öğe sayfasına yönlendirir", + "showVisualizerInSidebar_description": "Oynatıcı yan paneline görselleştiriciyi gösteren bir panel eklenecektir", + "showVisualizerInSidebar": "Oynatıcı yan panelinde görselleştiriciyi göster", + "combinedLyricsAndVisualizer_description": "Şarkı sözleri ve görselleştiriciyi aynı panelde birleştir", + "combinedLyricsAndVisualizer": "Şarkı sözleri ve görselleştiriciyi oynatıcı yan panelinde birleştir", + "sidebarPlaylistFolderSeparator": "Klasör ayırıcı", + "sidebarPlaylistFolderView_optionSingle": "Tek klasör", + "sidebarPlaylistFolderView_optionTree": "Ağaç liste görünümü" }, "table": { "column": { "album": "Albüm", "albumArtist": "Albüm sanatçısı", - "albumCount": "$t(entity.album, {\"count\": 2})", - "artist": "$t(entity.artist, {\"count\": 1})", + "albumCount": "Albümler", + "artist": "Sanatçı", "biography": "Biyografi", "bitrate": "Bit hızı", "bpm": "BPM (dakika başına vuruş)", - "channels": "$t(common.channel_other)", - "codec": "$t(common.codec)", + "channels": "Kanallar", + "codec": "Kodek", "comment": "Yorum", "dateAdded": "Tarih eklendi", "discNumber": "Disk", "favorite": "Favori", - "genre": "$t(entity.genre, {\"count\": 1})", + "genre": "Tür", "lastPlayed": "Son çalınan", "path": "Yol", "playCount": "Oynatılıyor", "rating": "Derecelendirme", "releaseDate": "Çıkış tarihi", "releaseYear": "Yıl", - "size": "$t(common.size)", + "size": "Boyut", "songCount": "$t(entity.track, {\"count\": 2})", "title": "Başlık", "trackNumber": "Parça" @@ -814,11 +970,31 @@ "secondary": { "demo": "Demo", "live": "Canlı", - "remix": "Remix" + "remix": "Remix", + "audiobook": "Sesli kitap" + }, + "primary": { + "album": "$t(entity.album, {\"count\": 1})", + "broadcast": "Yayın", + "ep": "EP", + "other": "Diğer", + "single": "Single" } }, "dragDropZone": { "error_oneFileOnly": "Lütfen sadece 1 dosya seç", "error_readingFile": "Bu dosyayi okurken bir sorun oluştu :{{errorMessage}}" + }, + "datetime": { + "minuteShort": "dk", + "secondShort": "sn", + "hourShort": "sa", + "dayShort": "g" + }, + "filterOperator": { + "notContains": "İçermez" + }, + "queryBuilder": { + "customTags": "Özel etiketler" } } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index e35917a62..0fbe2e8b7 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -40,7 +40,10 @@ "listenbrainz": "Відкрити у ListenBrainz", "qobuz": "Відкрити у Qobuz", "spotify": "Відкрити у Spotify" - } + }, + "goToCurrent": "Перейти до поточного елементу", + "collapseAllFolders": "Згорнути всі папки", + "expandAllFolders": "Розгорнути всі папки" }, "common": { "countSelected": "Вибрано {{count}}", @@ -164,21 +167,22 @@ "version": "Версія", "year": "Рік", "yes": "Так", - "explicit": "Експліцитний зміст", + "explicit": "Відвертий зміст", "gridRows": "Рядки сітки", "tableColumns": "Стовпці таблиці", "itemsMore": "{{count}} більше", "numberOfResults": "{{numberOfResults}} результатів", "newVersionAvailable": "Доступна нова версія", - "back": "Повернутися" + "back": "Повернутися", + "openFolder": "Відкрити папку" }, "entity": { "album_one": "Альбом", "album_few": "альбоми", "album_many": "альбомів", - "albumArtist_one": "Виконавець альбому", - "albumArtist_few": "виконавці альбому", - "albumArtist_many": "виконавців альбому", + "albumArtist_one": "Виконавець Альбому", + "albumArtist_few": "Виконавці Альбому", + "albumArtist_many": "Виконавці Альбому", "albumArtistCount_one": "{{count}} виконавець альбому", "albumArtistCount_few": "{{count}} виконавці альбому", "albumArtistCount_many": "{{count}} виконавців альбому", @@ -186,8 +190,8 @@ "albumWithCount_few": "{{count}} альбоми", "albumWithCount_many": "{{count}} альбомів", "radioStation_one": "Радіостанція", - "radioStation_few": "радіостанції", - "radioStation_many": "радіостанцій", + "radioStation_few": "Радіостанції", + "radioStation_many": "Радіостанцій", "radioStationWithCount_one": "{{count}} радіостанція", "radioStationWithCount_few": "{{count}} радіостанції", "radioStationWithCount_many": "{{count}} радіостанцій", @@ -263,7 +267,8 @@ "systemFontError": "Сталася помилка під час спроби отримати системні шрифти", "settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни", "invalidJson": "Недійсний JSON", - "playbackPausedDueToError": "Відтворення було призупинено через помилку" + "playbackPausedDueToError": "Відтворення було призупинено через помилку", + "serverLockSingleServer": "Коли сервер заблоковано можна використовувати тільки один сервер" }, "filter": { "album": "$t(entity.album, {\"count\": 1})", @@ -338,7 +343,9 @@ "notContains": "Не містить", "notInPlaylist": "Немає в", "notInTheLast": "Не є в останньому", - "startsWith": "Починається з" + "startsWith": "Починається з", + "isMissing": "Загублений", + "isPresent": "Присутній" }, "form": { "addServer": { @@ -369,7 +376,8 @@ "input_skipDuplicates": "Пропустити дублікати", "searchOrCreate": "Шукайте $t(entity.playlist, {\"count\": 2}) або пишіть, щоб створити новий", "success": "Додано $t(entity.trackWithCount, {\"count\": {{message}} }) до $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", - "title": "Додати до $t(entity.playlist, {\"count\": 1})" + "title": "Додати до $t(entity.playlist, {\"count\": 1})", + "noneAdded": "Ніяких треків не було додано до $t(entity.playlist, {\"count\": 1}) '{{playlist}}'" }, "createPlaylist": { "input_description": "$t(common.description)", @@ -437,7 +445,12 @@ "input_played": "Відтворити фільтр", "input_played_optionAll": "Всі треки", "input_played_optionUnplayed": "Тільки не відтворені треки", - "input_played_optionPlayed": "Тільки відтворені треки" + "input_played_optionPlayed": "Тільки відтворені треки", + "input_kind_albums": "Альбоми", + "input_kind_songs": "Треки", + "input_kind": "Випадкові вибори", + "input_limit_albums": "Скільки альбомів?", + "input_limit_songs": "Скільки треків?" }, "updateServer": { "success": "Сервер успішно оновлено", @@ -453,7 +466,57 @@ } }, "player": { - "skip": "Пропустити" + "skip": "Пропустити", + "repeat": "Повторювати", + "repeat_all": "Повторювати всі", + "repeat_off": "Повторювання вимкнено", + "restoreQueueFromServer": "Відновити чергу з серверу", + "saveQueueToServer": "Зберегти чергу до серверу", + "shuffle": "Грати (перемішано)", + "shuffle_off": "Перемішування вимкнено", + "addLast": "Останній", + "addNext": "Наступним", + "addLastShuffled": "Останнім (перемішано)", + "addNextShuffled": "Наступним (перемішано)", + "albumRadio": "Радіо альбому", + "artistRadio": "Радіо артиста", + "holdToShuffle": "Утримуйте щоб перемішати", + "favorite": "Додати до вибраних", + "lyrics": "Тексти пісень", + "mute": "Вимкнути звук", + "muted": "Звук вимкнено", + "next": "Наступний", + "play": "Грати", + "playbackFetchCancel": "Будь ласка, трошки почекайте... закрийте повідомлення, щоб скасувати", + "playbackFetchInProgress": "Завантаження треків…", + "playbackFetchNoResults": "Треків не знайдено", + "playbackSpeed": "Швидкість відтворення", + "playRandom": "Грати випадково", + "playSimilarSongs": "Грати схожі треки", + "previous": "Попередній", + "queue_clear": "Очистити чергу", + "queue_moveToBottom": "Пересунути виділені вниз", + "queue_moveToTop": "Пересунути виділені угору", + "queue_remove": "Видалити виділені", + "skip_back": "Перемотати назад", + "skip_forward": "Перемотати вперед", + "stop": "Зупинити", + "toggleFullscreenPlayer": "Перемкнути повноекранний плеєр", + "trackRadio": "Радіо треку", + "unfavorite": "Прибрати з вибраних", + "pause": "Пауза", + "viewQueue": "Переглянути чергу", + "sleepTimer": "Таймер сну", + "sleepTimer_endOfSong": "Кінець поточного треку", + "sleepTimer_endOfAlbum": "Кінець поточного альбому", + "sleepTimer_minutes": "{{count}} хв", + "sleepTimer_hours": "{{count}} г", + "sleepTimer_custom": "Користувацький", + "sleepTimer_off": "Вимкнено", + "sleepTimer_timeRemaining": "{{time}} залишилось", + "sleepTimer_setCustom": "Встановити таймер", + "sleepTimer_cancel": "Скасувати таймер", + "scrobbleForceSubmit": "Змусити скробблинути" }, "page": { "albumArtistDetail": { @@ -570,7 +633,9 @@ "showLyricProvider": "Показувати джерело тексту пісень", "synchronized": "Синхронізовано", "unsynchronized": "Несинхронізовано", - "useImageAspectRatio": "Використовувати співвідношення сторін зображення" + "useImageAspectRatio": "Використовувати співвідношення сторін зображення", + "lyricOpacityNonActive": "Непрозорість неактивних слів", + "lyricScaleNonActive": "Масштаб неактивних слів" }, "lyrics": "Текст пісні", "related": "Пов'язані", @@ -599,7 +664,123 @@ "genres": "$t(entity.genre, {\"count\": 2})", "mostPlayed": "Найбільш відтворені", "newlyAdded": "Нещодавно додані релізи", - "recentlyPlayed": "Нещодавно відтворені" + "recentlyPlayed": "Нещодавно відтворені", + "recentlyReleased": "Нещодавно випущені", + "title": "$t(common.home)" + }, + "itemDetail": { + "copyPath": "Скопіювати шлях до буфера обміну", + "copiedPath": "Шлях успішно скопійовано", + "openFile": "Показати трек у файловому менеджері" + }, + "setting": { + "sidebar": "Бічна панель", + "remote": "Віддалений", + "exportImport": "Імпортувати/експортувати", + "scrobble": "Скробблінг", + "audio": "Аудіо", + "lyrics": "Тексти пісень", + "lyricsDisplay": "Відображення текстів пісень", + "transcoding": "Транскодування", + "discord": "Діскорд", + "logger": "Логгер", + "playerFilters": "Фільтри плеєра", + "advanced": "Розширені", + "analytics": "Аналітика", + "generalTab": "Загальні", + "hotkeysTab": "Гарячі клавіші", + "playbackTab": "Відтворення", + "windowTab": "Вікно", + "updates": "Оновлення", + "cache": "Кеш", + "application": "Застосунок", + "queryBuilder": "Конструктор черги", + "theme": "Тема", + "controls": "Керування" + }, + "sidebar": { + "albumArtists": "$t(entity.albumArtist, {\"count\": 2})", + "albums": "$t(entity.album, {\"count\": 2})", + "collections": "Колекції", + "artists": "$t(entity.artist, {\"count\": 2})", + "favorites": "$t(entity.favorite, {\"count\": 2})", + "folders": "$t(entity.folder, {\"count\": 2})", + "genres": "$t(entity.genre, {\"count\": 2})", + "home": "$t(common.home)", + "radio": "$t(entity.radioStation, {\"count\": 2})", + "myLibrary": "Моя бібліотека", + "nowPlaying": "Зараз грає", + "playlists": "$t(entity.playlist, {\"count\": 2})", + "search": "$t(common.search)", + "settings": "$t(common.setting, {\"count\": 2})", + "shared": "Поширено $t(entity.playlist, {\"count\": 2})", + "tracks": "$t(entity.track, {\"count\": 2})" + }, + "trackList": { + "artistTracks": "Треки {{artist}}", + "genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})", + "title": "$t(entity.track, {\"count\": 2})" + }, + "playlistList": { + "title": "$t(entity.playlist, {\"count\": 2})" + }, + "collections": { + "overrideExisting": "Перевизначити існуючі", + "saveAsCollection": "Зберегти як колекцію" } + }, + "queryBuilder": { + "standardTags": "Стандартні теги", + "customTags": "Користувацькі теги" + }, + "releaseType": { + "primary": { + "album": "$t(entity.album, {\"count\": 1})", + "broadcast": "Транслювати", + "ep": "Міні-альбом", + "other": "Інші", + "single": "Сінгл" + }, + "secondary": { + "audiobook": "Аудіокнига", + "audioDrama": "Радіоп'єса", + "compilation": "Збірка", + "djMix": "DJ мікс", + "demo": "Демо", + "fieldRecording": "Запис поза студією", + "interview": "Інтерв'ю", + "live": "Наживо", + "mixtape": "Мікстейп", + "remix": "Ремікс", + "soundtrack": "Саундтрек", + "spokenWord": "Усне слово" + } + }, + "setting": { + "autoDJ": "Авто DJ", + "autoDJ_itemCount": "Кількість елементів", + "autoDJ_itemCount_description": "Кількість елементів, які будуть додані до черги", + "autoDJ_timing": "Таймінг", + "autoDJ_timing_description": "Кількість треків залишившихся в черзі перед тим, як авто DJ запрацює", + "autoDJ_mode": "Режим", + "autoDJ_mode_albums": "Альбоми", + "autoDJ_mode_description": "Оберіть, додавати треки чи цілі альбоми до черги", + "autoDJ_mode_songs": "Треки", + "autoDJ_enabled": "Увімкнути Авто DJ", + "autoDJ_albumStrategy": "Режим вибора альбомів", + "autoDJ_songStrategy": "Режим вибора треків", + "autoDJ_strategy_option_library_random": "Випадково", + "autoDJ_strategy_option_similar": "Схожі", + "autosave": "Автоматично зберігати чергу відтворення", + "autosave_description": "Увімкнути автоматичне збереження черги відтворення до вашого серверу. Це можливо тільки коли використовується Navidrome/Subsonic.Також, ви не можете мати міксовану чергу відтворення.", + "autosaveCount": "Частота автоматичного збереження черги відтворення", + "autosaveCount_description": "Кількість зміни трека перед збереженням черги. 1 (мінімум) означає змінення кожного трека", + "accentColor_description": "Встановлює акцентний колір для застосунка", + "accentColor": "Акцентний колір", + "useThemeAccentColor": "Використовувати акцентний колір теми", + "useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру", + "useThemePrimaryShade": "Використовувати основний відтінок теми", + "useThemePrimaryShade_description": "Використовувати основний відтінок, визначений у обраній темі, для основних варіантів кольорів", + "primaryShade": "Основний відтінок" } } diff --git a/src/i18n/locales/zh-Hans.json b/src/i18n/locales/zh-Hans.json index 3789d82d4..1de2655cd 100644 --- a/src/i18n/locales/zh-Hans.json +++ b/src/i18n/locales/zh-Hans.json @@ -41,7 +41,9 @@ "createRadioStation": "创建$t(entity.radioStation, {\"count\": 1})", "deleteRadioStation": "删除$t(entity.radioStation, {\"count\": 1})", "openApplicationDirectory": "打开应用程序目录", - "goToCurrent": "转到当前项目" + "goToCurrent": "转到当前项目", + "collapseAllFolders": "折叠所有文件夹", + "expandAllFolders": "展开所有文件夹" }, "common": { "increase": "增高", @@ -162,7 +164,9 @@ "filter_multiple": "多项", "newVersionAvailable": "新版本现已可用", "numberOfResults": "{{numberOfResults}} 结果", - "grouping": "分组" + "grouping": "分组", + "back": "返回", + "openFolder": "打开文件夹" }, "entity": { "albumArtist_other": "专辑艺术家", @@ -611,7 +615,9 @@ "sidePlayQueueLayout_optionHorizontal": "水平", "sidePlayQueueLayout_optionVertical": "垂直", "waveformLoadingDelay": "波形加载延迟", - "waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。" + "waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。", + "autoDJ_strategy_option_library_random": "随机", + "autoDJ_strategy_option_similar": "相似" }, "error": { "remotePortWarning": "重启服务器使新端口生效", @@ -729,7 +735,8 @@ "followCurrentLyric": "跟随当前歌词", "dynamicImageBlur": "图像模糊大小", "dynamicIsImage": "启用背景图像", - "lyricOffset": "歌词延迟补偿(毫秒)" + "lyricOffset": "歌词延迟补偿(毫秒)", + "lyricOpacityNonActive": "静态歌词不透明度" }, "lyrics": "歌词", "related": "相关", @@ -937,7 +944,8 @@ "input_skipDuplicates": "跳过重复", "input_playlists": "$t(entity.playlist, {\"count\": 2})", "create": "创建 $t(entity.playlist, {\"count\": 1}) {{playlist}}", - "searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的" + "searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的", + "noneAdded": "没有音轨被添加到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'" }, "createPlaylist": { "title": "创建$t(entity.playlist, {\"count\": 1})", @@ -1013,7 +1021,12 @@ "input_played_optionUnplayed": "仅未播放的曲目", "input_played_optionPlayed": "仅已播放的曲目", "input_limit": "有多少首歌?", - "input_played": "播放筛选器" + "input_played": "播放筛选器", + "input_kind_albums": "专辑", + "input_kind_songs": "歌曲", + "input_kind": "随机播放", + "input_limit_albums": "有多少专辑?", + "input_limit_songs": "有多少歌曲?" }, "editRadioStation": { "success": "电台更新成功" @@ -1175,7 +1188,8 @@ "startsWith": "以…开头", "inTheRangeDate": "在(日期)范围内", "notInPlaylist": "不在…中", - "notInTheLast": "不在最后" + "notInTheLast": "不在最后", + "isMissing": "丢失的项目" }, "datetime": { "minuteShort": "分", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 4b1ff7eae..7025d0be3 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -237,13 +237,21 @@ "dynamicIsImage": "啟用背景圖片", "lyricOffset": "歌詞偏移時間 (ms)", "lyricOpacityNonActive": "非活躍歌詞的不透明度", - "lyricScaleNonActive": "非活躍歌詞的比例" + "lyricScaleNonActive": "非活躍歌詞的比例", + "lyricFollowScrollAlignment": "歌詞跟隨滾動對齊", + "lyricLineLeadTime": "歌詞提前顯示時間 (ms)", + "lyricPaddingLeft": "歌詞左側邊距 (%)", + "lyricPaddingRight": "歌詞右側邊距 (%)" }, "lyrics": "歌詞", "related": "相關", "upNext": "即將播放", "visualizer": "視覺化", - "noLyrics": "未找到歌詞" + "noLyrics": "未找到歌詞", + "lyricLayers": "歌詞圖層", + "lyricLanguage": "語言", + "showPronunciation": "發音", + "showTranslation": "翻譯" }, "playlistList": { "title": "$t(entity.playlist, {\"count\": 2})" @@ -828,7 +836,50 @@ "autoDJ_albumStrategy": "專輯選擇模式", "autoDJ_songStrategy": "歌曲選擇模式", "autoDJ_strategy_option_library_random": "隨機", - "autoDJ_strategy_option_similar": "相似" + "autoDJ_strategy_option_similar": "相似", + "enableFurigana_description": "在日文歌詞漢字上方顯示發音標記(振假名)。", + "enableFurigana": "啟用振假名顯示", + "equalizer_descriptionMpv": "透過 FFmpeg lavfi (MPV) 使用參數等化器", + "equalizer_descriptionWebAudio": "透過 Web Audio API 使用參數等化器", + "equalizer": "等化器", + "equalizerBands_description": "各頻段增益。可上下拖曳或輸入數值。範圍:-12 至 +12 dB。", + "equalizerBands": "頻段", + "equalizerPreamp_description": "EQ 頻段之前的輸入增益。提升頻段時可設為負值以避免削波 (MPV)。", + "equalizerPreamp": "前級增益", + "equalizerPreset_description": "套用內建或已儲存的自訂 EQ 曲線", + "equalizerPreset": "預設", + "equalizerPresetDeletePlaceholder": "刪除自訂…", + "equalizerPresetGroupBuiltIn": "內建", + "equalizerPresetGroupCustom": "自訂", + "equalizerPresetNamePlaceholder": "預設名稱…", + "equalizerPresetSelectPlaceholder": "選擇預設", + "equalizerSavePreset_description": "將目前 EQ 設定儲存為具名預設", + "equalizerSavePreset": "儲存預設", + "compressor_descriptionMpv": "透過 FFmpeg acompressor (MPV) 使用動態範圍壓縮器", + "compressor_descriptionWebAudio": "透過 Web Audio API 使用動態範圍壓縮器", + "compressor": "壓縮器", + "compressorAttack_description": "訊號超過閾值後,壓縮器開始作用的速度。", + "compressorAttack": "啟動時間", + "compressorKnee_description": "柔性拐點寬度。數值越高,進入壓縮的過渡越平滑。", + "compressorKnee": "拐點", + "compressorMakeupGain_description": "壓縮後套用的輸出增益,用於恢復音量。", + "compressorMakeupGain": "補償增益", + "compressorPreset_description": "套用內建或已儲存的自訂壓縮器設定", + "compressorRatio_description": "壓縮比例,例如 4 表示 4:1。", + "compressorRatio": "比例", + "compressorRelease_description": "訊號低於閾值後,壓縮器解除作用的速度。", + "compressorRelease": "釋放時間", + "compressorReset_description": "將所有壓縮器參數恢復為預設值", + "compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設", + "compressorThreshold_description": "開始進行壓縮的訊號電平。", + "compressorThreshold": "閥值", + "enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。", + "enableRomaji": "啟用羅馬拼音顯示", + "windowBarTrackinfo": "在視窗標題列顯示曲目資訊", + "windowBarTrackinfo_description": "在視窗標題列中顯示目前播放曲目的標題與藝人、播放佇列中的位置,以及播放/暫停狀態。", + "audioDeviceDefault": "系統預設", + "microtonalPitchControls": "微音程音高控制", + "microtonalPitchControls_description": "新增更多音高控制選項,以在半音之間進行微調" }, "table": { "config": { @@ -862,7 +913,11 @@ "horizontalBorders": "行邊框線", "rowHoverHighlight": "滑鼠懸停Highlight", "verticalBorders": "列邊框線", - "showHeader": "顯示標題" + "showHeader": "顯示標題", + "albumGroupConfig": "專輯群組設定", + "albumImageSize": "專輯圖片大小", + "albumGroupMetadata": "中繼資料欄位", + "albumGroupShowFavoriteRating": "顯示最愛與評分" }, "label": { "actions": "$t(common.action, {\"count\": 2})", @@ -1072,7 +1127,8 @@ "input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用", "input_preferRemoteUrl": "優先使用公開網址", "input_remoteUrl": "公開網址", - "input_remoteUrlPlaceholder": "選用:對外功能的公開網址" + "input_remoteUrlPlaceholder": "選用:對外功能的公開網址", + "input_passwordNoSSO": "不支援反向代理(SSO)驗證。僅支援使用者名稱/密碼驗證及 Subsonic 驗證" }, "addToPlaylist": { "input_playlists": "$t(entity.playlist, {\"count\": 2})", @@ -1224,7 +1280,9 @@ "notInPlaylist": "不在…之中", "startsWith": "以…開頭", "inTheLast": "在最後", - "notInTheLast": "不在最後" + "notInTheLast": "不在最後", + "isMissing": "不存在", + "isPresent": "存在" }, "datetime": { "minuteShort": "分", diff --git a/src/main/features/core/lyrics/furigana.ts b/src/main/features/core/lyrics/furigana.ts new file mode 100644 index 000000000..d97a02873 --- /dev/null +++ b/src/main/features/core/lyrics/furigana.ts @@ -0,0 +1,146 @@ +import Kuroshiro from 'kuroshiro'; +import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji'; +import { hasJapanese, kanaToRomaji, patchTokens } from 'kuroshiro/lib/util'; + +// doc: https://kuroshiro.org + +export type LyricTextToken = { + endChar: number; + startChar: number; + text: string; +}; + +export type RomajiToken = LyricTextToken & { + romaji: string; +}; + +let kuroshiroInstance: any = null; +let initPromise: null | Promise = null; + +const getKuroshiro = async () => { + if (initPromise) { + await initPromise; + return kuroshiroInstance; + } + + if (kuroshiroInstance) return kuroshiroInstance; + + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + kuroshiroInstance = new KuroshiroClass(); + initPromise = kuroshiroInstance.init(new KuromojiAnalyzer()); + await initPromise; + + initPromise = null; + return kuroshiroInstance; +}; + +export const convertFurigana = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + // check if the text contains any Japanese kana (to distinguish Japanese from Chinese text, which shares Kanji) + // If no Japanese kana is detected, skip processing + if (!KuroshiroClass.Util.hasKana(text)) return text; + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' }); + } catch (e) { + console.error('Furigana conversion error: ', e); + return text; + } +}; + +export const convertFuriganaFragment = async (text: string): Promise => { + if (!text || !hasJapanese(text)) { + return text; + } + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' }); + } catch (e) { + console.error('Furigana fragment conversion error: ', e); + return text; + } +}; + +export const convertRomaji = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + if (!KuroshiroClass.Util.hasKana(text)) return ''; + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' }); + } catch (e) { + console.error('Romaji conversion error: ', e); + return ''; + } +}; + +export const parseLyricsTextTokens = async (text: string): Promise => { + if (!text || !hasJapanese(text)) { + return []; + } + + try { + const kuroshiro = await getKuroshiro(); + const rawTokens = await kuroshiro._analyzer.parse(text); + const tokens = patchTokens(rawTokens); + + let cursor = 0; + + return tokens.map((token: { surface_form: string }) => { + const surface = token.surface_form; + const startChar = cursor; + cursor += surface.length; + + return { + endChar: cursor, + startChar, + text: surface, + }; + }); + } catch (e) { + console.error('Lyrics token parse error: ', e); + return []; + } +}; + +export const convertRomajiTokens = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + if (!KuroshiroClass.Util.hasKana(text)) { + return []; + } + + try { + const kuroshiro = await getKuroshiro(); + const rawTokens = await kuroshiro._analyzer.parse(text); + const tokens = patchTokens(rawTokens); + + let cursor = 0; + + return tokens.map( + (token: { pronunciation?: string; reading: string; surface_form: string }) => { + const surface = token.surface_form; + const startChar = cursor; + cursor += surface.length; + + const romaji = hasJapanese(surface) + ? kanaToRomaji(token.pronunciation || token.reading) + : surface; + + return { + endChar: cursor, + romaji, + startChar, + text: surface, + }; + }, + ); + } catch (e) { + console.error('Romaji token conversion error: ', e); + return []; + } +}; diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index 28dd11c17..d6061db5a 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -1,6 +1,13 @@ import { ipcMain } from 'electron'; import { store } from '../settings'; +import { + convertFurigana, + convertFuriganaFragment, + convertRomaji, + convertRomajiTokens, + parseLyricsTextTokens, +} from './furigana'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease'; @@ -231,3 +238,23 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => { const lyricResults = await getRemoteLyricsById(params); return lyricResults; }); + +ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => { + return await convertFurigana(text); +}); + +ipcMain.handle('lyric-convert-furigana-fragment', async (_event, text: string) => { + return await convertFuriganaFragment(text); +}); + +ipcMain.handle('lyric-parse-text-tokens', async (_event, text: string) => { + return await parseLyricsTextTokens(text); +}); + +ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => { + return await convertRomaji(text); +}); + +ipcMain.handle('lyric-convert-romaji-tokens', async (_event, text: string) => { + return await convertRomajiTokens(text); +}); diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 8fd1899ab..f3961984e 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -1,5 +1,5 @@ import console from 'console'; -import { app, ipcMain } from 'electron'; +import { app, ipcMain, powerMonitor } from 'electron'; import { access, rm } from 'fs/promises'; import uniq from 'lodash/uniq'; import MpvAPI from 'node-mpv'; @@ -85,6 +85,19 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => { parameters.push('--prefetch-playlist=yes'); } + // Without these, mpv/ffmpeg will block indefinitely on a dead TCP connection + // instead of failing or reconnecting. This commonly happens when the OS network + // adapter resets after the system wakes from sleep while a stream is open. + if (!extraParameters?.some((param) => param.startsWith('--network-timeout'))) { + parameters.push('--network-timeout=10'); + } + + if (!extraParameters?.some((param) => param.startsWith('--stream-lavf-o'))) { + parameters.push( + '--stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_at_eof=1,reconnect_delay_max=5', + ); + } + return parameters; }; @@ -148,19 +161,29 @@ const createMpv = async (data: { await mpv.setMultipleProperties(properties || {}); } + let previousPlaylistPos: number | undefined; + mpv.on('status', (status) => { if (status.property === 'playlist-pos') { + const currentPos = typeof status.value === 'number' ? status.value : undefined; + // mpv uses playlist-pos = -1 when nothing is playing (ended, cleared, load failure, etc). - if (status.value === -1) { + if (currentPos === -1) { + if (previousPlaylistPos === 0) { + getMainWindow()?.webContents.send('renderer-player-track-ended'); + } mpv?.pause(); + previousPlaylistPos = currentPos; return; } // In our 2-item queue model, playlist-pos should normally be 0. // When mpv auto-advances to the next track it becomes > 0 (typically 1). - if (typeof status.value === 'number' && status.value > 0) { + if (typeof currentPos === 'number' && currentPos > 0) { getMainWindow()?.webContents.send('renderer-player-auto-next'); } + + previousPlaylistPos = currentPos; } }); @@ -191,21 +214,44 @@ export const getMpvInstance = () => { return mpvInstance; }; +const QUIT_TIMEOUT_MS = 3000; + +const killMpvProcess = (mpv: MpvAPI) => { + const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess; + if (mpvProcess && typeof mpvProcess.kill === 'function') { + try { + mpvProcess.kill('SIGTERM'); + } catch (killErr) { + mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError); + } + } +}; + const quit = async (instance?: MpvAPI | null) => { const mpv = instance || getMpvInstance(); if (mpv) { try { - await mpv.quit(); + // mpv.quit() resolves only when mpv replies over IPC. If mpv's command queue + // is wedged (e.g. blocked on a dead network stream after the system resumes + // from sleep), that reply never arrives, so this must not be allowed to hang + // forever - fall back to killing the process directly. + let timedOut = false; + await Promise.race([ + mpv.quit(), + new Promise((resolve) => { + setTimeout(() => { + timedOut = true; + resolve(undefined); + }, QUIT_TIMEOUT_MS); + }), + ]); + + if (timedOut) { + killMpvProcess(mpv); + } } catch { // If quit() fails, try to kill the process directly - const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess; - if (mpvProcess && typeof mpvProcess.kill === 'function') { - try { - mpvProcess.kill('SIGTERM'); - } catch (killErr) { - mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError); - } - } + killMpvProcess(mpv); } if (!isWindows()) { try { @@ -666,6 +712,17 @@ const cleanupMpv = async (force = false) => { } }; +// When the OS resumes from sleep, any network stream mpv had open is likely dead +// (the connection silently dropped while the network adapter was suspended). Tell +// the renderer to reload mpv so it reconnects with a fresh stream instead of staying +// stuck on the old, now-dead connection until the app is manually restarted. +powerMonitor.on('resume', () => { + if (getMpvInstance()) { + mpvLog({ action: 'System resumed from sleep, reloading mpv' }); + getMainWindow()?.webContents.send('renderer-mpv-reconnect'); + } +}); + app.on('before-quit', async (event) => { switch (mpvState) { case MpvState.DONE: diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts index f79417c6c..e1e1940d2 100644 --- a/src/main/features/linux/mpris.ts +++ b/src/main/features/linux/mpris.ts @@ -124,7 +124,12 @@ ipcMain.on('update-volume', (_event, volume) => { }); ipcMain.on('update-playback', (_event, status: PlayerStatus) => { - mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused'; + mprisPlayer.playbackStatus = + status === PlayerStatus.PLAYING + ? 'Playing' + : status === PlayerStatus.STOPPED + ? 'Stopped' + : 'Paused'; }); const REPEAT_TO_MPRIS: Record = { diff --git a/src/main/index.ts b/src/main/index.ts index 0d5f01f66..c98589516 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -70,16 +70,19 @@ class AppUpdater { console.log('Effective update channel:', effectiveChannel); if (effectiveChannel === 'alpha') { checkAllChannelsAndGetBest().then(({ result, updater: updaterInstance }) => { + if (!result?.isUpdateAvailable) { + return; + } + updaterInstance.autoInstallOnAppQuit = true; updaterInstance.autoRunAppAfterInstall = true; if (isMacOS()) { - if (result?.isUpdateAvailable) { - getMainWindow()?.webContents.send( - 'update-available', - result.updateInfo.version, - ); - } + getMainWindow()?.webContents.send( + 'update-available', + result.updateInfo.version, + ); } else { + updaterInstance.autoDownload = true; updaterInstance.checkForUpdatesAndNotify(); } }); @@ -120,12 +123,7 @@ async function checkAllChannelsAndGetBest(): Promise<{ updater: UpdaterInstance; }> = []; - const alphaUpdater = createAlphaUpdaterInstance(); - alphaUpdater.logger = autoUpdaterLogInterface; - alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel; - alphaUpdater.allowPrerelease = true; - alphaUpdater.disableDifferentialDownload = true; - alphaUpdater.allowDowngrade = true; + const alphaUpdater = createAlphaUpdaterInstance({ probeOnly: true }); try { console.log('Checking for updates on alpha channel'); @@ -143,17 +141,16 @@ async function checkAllChannelsAndGetBest(): Promise<{ } try { - autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG); - configureAutoUpdaterForChannel('latest'); + const latestUpdater = createGithubUpdaterInstance('latest', { probeOnly: true }); console.log('Checking for updates on latest channel (GitHub)'); - const latestResult = await autoUpdater.checkForUpdates(); + const latestResult = await latestUpdater.checkForUpdates(); if ( latestResult?.updateInfo?.version && latestResult.isUpdateAvailable && semver.valid(latestResult.updateInfo.version) && semver.gt(latestResult.updateInfo.version, currentVersion) ) { - candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater }); + candidates.push({ channel: 'latest', result: latestResult, updater: latestUpdater }); } } catch (e) { log.warn('Latest channel check failed', e); @@ -169,6 +166,7 @@ async function checkAllChannelsAndGetBest(): Promise<{ if (best.channel === 'latest') { configureAutoUpdaterForChannel('latest'); + return { result: best.result, updater: autoUpdater }; } return { result: best.result, updater: best.updater }; @@ -195,16 +193,8 @@ function configureAndGetUpdater(): UpdaterInstance { const effectiveChannel = store.get('release_channel') as string; if (effectiveChannel === 'alpha') { - const updater = createAlphaUpdaterInstance(); log.transports.file.level = 'info'; - updater.logger = autoUpdaterLogInterface; - updater.channel = ALPHA_UPDATER_CONFIG.channel; - updater.allowPrerelease = true; - updater.disableDifferentialDownload = true; - updater.allowDowngrade = true; - updater.autoInstallOnAppQuit = true; - updater.autoRunAppAfterInstall = true; - return updater; + return createAlphaUpdaterInstance(); } log.transports.file.level = 'info'; @@ -219,6 +209,7 @@ function configureAndGetUpdater(): UpdaterInstance { autoUpdater.disableDifferentialDownload = true; } else { autoUpdater.channel = 'latest'; + autoUpdater.allowDowngrade = false; autoUpdater.allowPrerelease = false; } @@ -241,20 +232,68 @@ function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void { autoUpdater.disableDifferentialDownload = true; } else { autoUpdater.channel = 'latest'; + autoUpdater.allowDowngrade = false; autoUpdater.allowPrerelease = false; } } -function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater { +function createAlphaUpdaterInstance( + options: { probeOnly?: boolean } = {}, +): AppImageUpdater | MacUpdater | NsisUpdater { + const probeOnly = options.probeOnly ?? false; + let updater: AppImageUpdater | MacUpdater | NsisUpdater; + if (isMacOS()) { - return new MacUpdater(ALPHA_UPDATER_CONFIG); + updater = new MacUpdater(ALPHA_UPDATER_CONFIG); + } else if (isLinux()) { + updater = new AppImageUpdater(ALPHA_UPDATER_CONFIG); + } else { + updater = new NsisUpdater(ALPHA_UPDATER_CONFIG); } - if (isLinux()) { - return new AppImageUpdater(ALPHA_UPDATER_CONFIG); + updater.logger = autoUpdaterLogInterface; + updater.channel = ALPHA_UPDATER_CONFIG.channel; + updater.allowPrerelease = true; + updater.disableDifferentialDownload = true; + updater.allowDowngrade = true; + updater.autoDownload = !probeOnly; + updater.autoInstallOnAppQuit = true; + updater.autoRunAppAfterInstall = true; + + return updater; +} + +function createGithubUpdaterInstance( + channel: 'beta' | 'latest', + options: { probeOnly?: boolean } = {}, +): AppImageUpdater | MacUpdater | NsisUpdater { + const probeOnly = options.probeOnly ?? false; + let updater: AppImageUpdater | MacUpdater | NsisUpdater; + + if (isMacOS()) { + updater = new MacUpdater(GITHUB_UPDATER_CONFIG); + } else if (isLinux()) { + updater = new AppImageUpdater(GITHUB_UPDATER_CONFIG); + } else { + updater = new NsisUpdater(GITHUB_UPDATER_CONFIG); } - return new NsisUpdater(ALPHA_UPDATER_CONFIG); + updater.logger = autoUpdaterLogInterface; + updater.autoDownload = !probeOnly; + updater.autoInstallOnAppQuit = true; + updater.autoRunAppAfterInstall = true; + updater.channel = channel; + + if (channel === 'beta') { + updater.allowDowngrade = true; + updater.allowPrerelease = true; + updater.disableDifferentialDownload = true; + } else { + updater.allowDowngrade = false; + updater.allowPrerelease = false; + } + + return updater; } protocol.registerSchemesAsPrivileged([ diff --git a/src/preload/lyrics.ts b/src/preload/lyrics.ts index b69ab3842..21c96cb0b 100644 --- a/src/preload/lyrics.ts +++ b/src/preload/lyrics.ts @@ -26,9 +26,34 @@ const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => { return result; }; +const convertFurigana = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-furigana', text); +}; + +const convertFuriganaFragment = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-furigana-fragment', text); +}; + +const parseLyricsTextTokens = (text: string) => { + return ipcRenderer.invoke('lyric-parse-text-tokens', text); +}; + +const convertRomaji = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-romaji', text); +}; + +const convertRomajiTokens = (text: string) => { + return ipcRenderer.invoke('lyric-convert-romaji-tokens', text); +}; + export const lyrics = { + convertFurigana, + convertFuriganaFragment, + convertRomaji, + convertRomajiTokens, getRemoteLyricsByRemoteId, getRemoteLyricsBySong, + parseLyricsTextTokens, searchRemoteLyrics, }; diff --git a/src/preload/mpv-player.ts b/src/preload/mpv-player.ts index f5a4f52b0..5b1fd3fd9 100644 --- a/src/preload/mpv-player.ts +++ b/src/preload/mpv-player.ts @@ -102,6 +102,10 @@ const getAudioDevices = async () => { return ipcRenderer.invoke('player-get-audio-devices'); }; +const rendererTrackEnded = (cb: () => void) => { + ipcRenderer.on('renderer-player-track-ended', () => cb()); +}; + const rendererAutoNext = (cb: (data: PlayerData) => void) => { ipcRenderer.on('renderer-player-auto-next', (_, data) => cb(data)); }; @@ -174,6 +178,10 @@ const rendererPlayerFallback = (cb: (data: boolean) => void) => { ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data)); }; +const rendererMpvReconnect = (cb: () => void) => { + ipcRenderer.on('renderer-mpv-reconnect', () => cb()); +}; + export const mpvPlayer = { autoNext, cleanup, @@ -205,6 +213,7 @@ export const mpvPlayerListener = { rendererAutoNext, rendererCurrentTime, rendererError, + rendererMpvReconnect, rendererNext, rendererPause, rendererPlay, @@ -217,6 +226,7 @@ export const mpvPlayerListener = { rendererStop, rendererToggleRepeat, rendererToggleShuffle, + rendererTrackEnded, rendererVolumeDown, rendererVolumeMute, rendererVolumeUp, diff --git a/src/remote/components/remote-container.tsx b/src/remote/components/remote-container.tsx index 4566cd82e..3d73208f3 100644 --- a/src/remote/components/remote-container.tsx +++ b/src/remote/components/remote-container.tsx @@ -128,7 +128,7 @@ export const RemoteContainer = () => { onClick={() => { if (status === PlayerStatus.PLAYING) { send({ event: 'pause' }); - } else if (status === PlayerStatus.PAUSED) { + } else { send({ event: 'play' }); } }} diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 3398fd67a..1117ba891 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -546,6 +546,18 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + getPlaylistSongIds(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistSongIds`); + } + + return apiController( + 'getPlaylistSongIds', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, getPlaylistSongList(args) { const server = getServerById(args.apiClientProps.serverId); @@ -750,6 +762,18 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + jukeboxControl(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error(`${i18n.t('error.apiRouteError')}: jukeboxControl`); + } + + const fn = apiController('jukeboxControl', server.type); + return fn + ? fn(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })) + : Promise.resolve(null); + }, movePlaylistItem(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 4e1e98c9c..8268974d1 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -197,8 +197,8 @@ const JF_FIELDS = { 'SortName', 'ProviderIds', ], - ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'People', 'Tags', 'ProviderIds'], - ALBUM_LIST: ['People', 'Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'], + ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'Tags', 'ProviderIds'], + ALBUM_LIST: ['Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'], FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'], GENRE: ['ItemCounts'], PLAYLIST_DETAIL: [ @@ -210,16 +210,7 @@ const JF_FIELDS = { 'SortName', ], PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'], - SONG: [ - 'Genres', - 'DateCreated', - 'MediaSources', - 'ParentId', - 'People', - 'Tags', - 'SortName', - 'ProviderIds', - ], + SONG: ['Genres', 'DateCreated', 'MediaSources', 'ParentId', 'Tags', 'SortName', 'ProviderIds'], } as const; export const JellyfinController: InternalControllerEndpoint = { @@ -531,12 +522,7 @@ export const JellyfinController: InternalControllerEndpoint = { const albumIdSet = new Set([query.id]); const songs = songsRes.body.Items.filter((item) => albumIdSet.has(item.AlbumId!)); - return jfNormalize.album( - { ...res.body, Songs: songs }, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ); + return jfNormalize.album({ ...res.body, Songs: songs }, apiClientProps.server); }, getAlbumList: async (args) => { const { apiClientProps, query } = args; @@ -630,14 +616,7 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get album radio songs'); } - return res.body.Items.map((song) => - jfNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server)); }, getArtistList: async (args) => { const { apiClientProps, query } = args; @@ -693,14 +672,7 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get artist radio songs'); } - return res.body.Items.map((song) => - jfNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server)); }, getDownloadUrl: (args) => { const { apiClientProps, query } = args; @@ -870,8 +842,6 @@ export const JellyfinController: InternalControllerEndpoint = { jfNormalize.song( item as unknown as z.infer, apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, ), ); @@ -986,7 +956,10 @@ export const JellyfinController: InternalControllerEndpoint = { return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); } - return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); + return res.body.Lyrics.map((lyric) => ({ + startMs: lyric.Start! / 1e4, + text: lyric.Text, + })); }, getMusicFolderList: async (args) => { const { apiClientProps } = args; @@ -1053,7 +1026,7 @@ export const JellyfinController: InternalControllerEndpoint = { Fields: JF_FIELDS.PLAYLIST_LIST, IncludeItemTypes: 'Playlist', Limit: query.limit, - MediaTypes: 'Audio', + MediaTypes: 'Audio, Unknown', Recursive: true, SearchTerm: query.searchTerm, SortBy: playlistListSortMap.jellyfin[query.sortBy], @@ -1077,6 +1050,35 @@ export const JellyfinController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getPlaylistSongIds: async (args) => { + const { apiClientProps, query } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistSongList({ + params: { + id: query.id, + }, + query: { + // XXX: No fields are required for only IDs, which saves processing time between + // the Jellyfin server query, network (MBs vs KBs), and in-app parsing. + IncludeItemTypes: 'Audio', + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list IDs'); + } + + return { + items: res.body.Items.map((item) => item.Id), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + }, getPlaylistSongList: async (args) => { const { apiClientProps, query } = args; @@ -1089,7 +1091,7 @@ export const JellyfinController: InternalControllerEndpoint = { id: query.id, }, query: { - Fields: JF_FIELDS.SONG, + Fields: JF_FIELDS.PLAYLIST_DETAIL, IncludeItemTypes: 'Audio', UserId: apiClientProps.server?.userId, }, @@ -1100,14 +1102,7 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: res.body.Items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; @@ -1160,14 +1155,7 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: res.body.Items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: res.body.Items.length || 0, }; @@ -1219,14 +1207,7 @@ export const JellyfinController: InternalControllerEndpoint = { if (res.status === 200 && res.body.Items.length) { const results = res.body.Items.reduce((acc, song) => { if (song.Id !== query.songId) { - acc.push( - jfNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + acc.push(jfNormalize.song(song, apiClientProps.server)); } return acc; @@ -1255,14 +1236,7 @@ export const JellyfinController: InternalControllerEndpoint = { return mix.body.Items.reduce((acc, song) => { if (song.Id !== query.songId) { - acc.push( - jfNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + acc.push(jfNormalize.song(song, apiClientProps.server)); } return acc; @@ -1282,12 +1256,7 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get song detail'); } - return jfNormalize.song( - res.body, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ); + return jfNormalize.song(res.body, apiClientProps.server); }, getSongList: async (args) => { const { apiClientProps, query } = args; @@ -1399,14 +1368,7 @@ export const JellyfinController: InternalControllerEndpoint = { } return { - items: items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + items: items.map((item) => jfNormalize.song(item, apiClientProps.server)), startIndex: query.startIndex, totalRecordCount, }; @@ -1538,14 +1500,7 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get top song list'); } - const items = res.body.Items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + const items = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)); if (type === 'personal') { const sorted = orderBy( @@ -1647,12 +1602,7 @@ export const JellyfinController: InternalControllerEndpoint = { } const existingSongs = existingSongsRes.body.Items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + jfNormalize.song(item, apiClientProps.server), ); // 2. Get playlist detail to get the name @@ -1903,14 +1853,7 @@ export const JellyfinController: InternalControllerEndpoint = { jfNormalize.albumArtist(item, apiClientProps.server), ), albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)), - songs: songs.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server)), }; }, setPlaylistSongs: async (args) => { diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index bfb8bc545..b33511ad9 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -19,7 +19,6 @@ import { DeleteInternetRadioStationImageResponse, DeletePlaylistImageArgs, DeletePlaylistImageResponse, - genreListSortMap, InternalControllerEndpoint, playlistListSortMap, PlaylistSongListArgs, @@ -367,7 +366,7 @@ export const NavidromeController: InternalControllerEndpoint = { query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), getAlbumDetail: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({ params: { @@ -393,8 +392,6 @@ export const NavidromeController: InternalControllerEndpoint = { return ndNormalize.album( { ...albumRes.body.data, songs: songsData.body.data }, apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, ); }, getAlbumInfo: async (args) => { @@ -418,7 +415,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, getAlbumList: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const genres = hasFeature(apiClientProps.server, ServerFeature.BFR) ? query.genreIds @@ -453,14 +450,7 @@ export const NavidromeController: InternalControllerEndpoint = { } return { - items: res.body.data.map((album) => - ndNormalize.album( - album, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ), + items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)), startIndex: query?.startIndex || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; @@ -493,12 +483,7 @@ export const NavidromeController: InternalControllerEndpoint = { } return res.body.similarSongs.song.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ); }, getArtistList: async (args) => { @@ -568,12 +553,7 @@ export const NavidromeController: InternalControllerEndpoint = { } return res.body.similarSongs2.song.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ); }, getDownloadUrl: SubsonicController.getDownloadUrl, @@ -615,26 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; } - const res = await ndApiClient(apiClientProps).getGenreList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: genreListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - library_id: getLibraryId(query.musicFolderId), - name: query.searchTerm, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get genre list'); - } - - return { - items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)), - startIndex: query.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; + return SubsonicController.getGenreList(args); }, getImageRequest: SubsonicController.getImageRequest, getImageUrl: SubsonicController.getImageUrl, @@ -702,6 +663,11 @@ export const NavidromeController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getPlaylistSongIds: async (args) => + NavidromeController.getPlaylistSongList(args).then((result) => ({ + ...result, + items: result.items.map((song) => song.id), + })), getPlaylistSongList: async (args: PlaylistSongListArgs): Promise => { const { apiClientProps, query } = args; @@ -723,14 +689,7 @@ export const NavidromeController: InternalControllerEndpoint = { } return { - items: res.body.data.map((item) => - ndNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)), startIndex: 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; @@ -747,14 +706,7 @@ export const NavidromeController: InternalControllerEndpoint = { const { changedBy, current, items = [], position, updatedAt } = res.body.data; // if there is no queue saved, items is undefined - const entries = items.map((song) => - ndNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + const entries = items.map((song) => ndNormalize.song(song, apiClientProps.server)); return { changed: updatedAt, @@ -793,6 +745,7 @@ export const NavidromeController: InternalControllerEndpoint = { ...navidromeFeatures, publicPlaylist: [1], [ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1], + [ServerFeature.JUKEBOX]: [1], [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], }; @@ -830,14 +783,7 @@ export const NavidromeController: InternalControllerEndpoint = { return ( (res.body.similarSongs?.song || []) .filter((song) => song.id !== query.songId) - .map((song) => - ssNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ) || [] + .map((song) => ssNormalize.song(song, apiClientProps.server)) || [] ); }, getSongDetail: async (args) => { @@ -853,12 +799,7 @@ export const NavidromeController: InternalControllerEndpoint = { throw new Error('Failed to get song detail'); } - return ndNormalize.song( - res.body.data, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ); + return ndNormalize.song(res.body.data, apiClientProps.server); }, getSongList: async (args) => { const { apiClientProps, query } = args; @@ -898,14 +839,7 @@ export const NavidromeController: InternalControllerEndpoint = { } return { - items: res.body.data.map((song) => - ndNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server)), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }; @@ -933,12 +867,12 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: albums.totalRecordCount, }; }, - getSongListCount: async ({ apiClientProps, query }) => NavidromeController.getSongList({ apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getStreamUrl: SubsonicController.getStreamUrl, getStructuredLyrics: SubsonicController.getStructuredLyrics, getTagList: async (args) => { @@ -1022,12 +956,7 @@ export const NavidromeController: InternalControllerEndpoint = { return { items: (res.body.topSongs?.song || []).map((song) => - ssNormalize.song( - song, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ), startIndex: 0, totalRecordCount: res.body.topSongs?.song?.length || 0, @@ -1036,7 +965,6 @@ export const NavidromeController: InternalControllerEndpoint = { const res = await NavidromeController.getSongList({ apiClientProps, - context: args.context, query: { artistIds: [query.artistId], sortBy: SongListSort.PLAY_COUNT, @@ -1081,6 +1009,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, + jukeboxControl: SubsonicController.jukeboxControl, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; @@ -1138,12 +1067,7 @@ export const NavidromeController: InternalControllerEndpoint = { } const existingSongs = existingSongsRes.body.data.map((item) => - ndNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ndNormalize.song(item, apiClientProps.server), ); // 2. Get playlist detail to get the name diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 84f0f2121..94ac40302 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -338,6 +338,9 @@ export const queryKeys: Record< return [serverId, 'playlists', 'songList'] as const; }, + songListIds: (serverId: string, id: string) => { + return [serverId, 'playlists', 'songListIds', id] as const; + }, }, radio: { list: (serverId: string) => [serverId, 'radio', 'list'] as const, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 3f9898cba..2badcf356 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -282,6 +282,14 @@ export const contract = c.router({ 200: ssType._response.user, }, }, + jukeboxControl: { + method: 'GET', + path: 'jukeboxControl.view', + query: ssType._parameters.jukeboxControl, + responses: { + 200: ssType._response.jukeboxControl, + }, + }, ping: { method: 'GET', path: 'ping.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 39e08940d..fcb26b5dc 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -8,6 +8,7 @@ import md5 from 'md5'; import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { mapStructuredLyric } from '/@/renderer/api/subsonic/subsonic-structured-lyrics'; import { getDefaultTranscodingProfiles, getDirectPlayProfiles, @@ -21,7 +22,13 @@ import { ssType, SubsonicExtensions, } from '/@/shared/api/subsonic/subsonic-types'; -import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils'; +import { + hasFeature, + hasFeatureWithVersion, + sortAlbumArtistList, + sortAlbumList, + sortSongList, +} from '/@/shared/api/utils'; import { AlbumListSort, GenreListSort, @@ -482,14 +489,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { ...ssNormalize.albumArtist(artist, apiClientProps.server), - albums: artist.album?.map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ), + albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)), similarArtists: null, }; }, @@ -564,7 +564,6 @@ export const SubsonicController: InternalControllerEndpoint = { getAlbumArtistListCount: (args) => SubsonicController.getAlbumArtistList({ ...args, - context: args.context, query: { ...args.query, startIndex: 0 }, }).then((res) => res!.totalRecordCount!), getAlbumDetail: async (args) => { @@ -580,12 +579,7 @@ export const SubsonicController: InternalControllerEndpoint = { throw new Error('Failed to get album detail'); } - return ssNormalize.album( - res.body.album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ); + return ssNormalize.album(res.body.album, apiClientProps.server); }, getAlbumList: async (args) => { const { apiClientProps, query } = args; @@ -610,12 +604,7 @@ export const SubsonicController: InternalControllerEndpoint = { const results = res.body.searchResult3?.album?.map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.album(album, apiClientProps.server), ) || []; return { @@ -650,14 +639,7 @@ export const SubsonicController: InternalControllerEndpoint = { return artist.body.artist.album ?? []; }); - const items = albums.map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), - ); + const items = albums.map((album) => ssNormalize.album(album, apiClientProps.server)); return { items: sortAlbumList(items, query.sortBy, query.sortOrder), @@ -679,12 +661,7 @@ export const SubsonicController: InternalControllerEndpoint = { const allResults = res.body.starred?.album?.map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.album(album, apiClientProps.server), ) || []; return sortAndPaginate(allResults, { @@ -749,12 +726,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { items: res.body.albumList2.album?.map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.album(album, apiClientProps.server), ) || [], startIndex: query.startIndex, totalRecordCount: null, @@ -905,7 +877,7 @@ export const SubsonicController: InternalControllerEndpoint = { return totalRecordCount; }, getAlbumRadio: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs({ query: { @@ -923,12 +895,7 @@ export const SubsonicController: InternalControllerEndpoint = { } return res.body.similarSongs.song.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ); }, getArtistList: async (args) => { @@ -974,11 +941,10 @@ export const SubsonicController: InternalControllerEndpoint = { getArtistListCount: async (args) => SubsonicController.getArtistList({ ...args, - context: args.context, query: { ...args.query, startIndex: 0 }, }).then((res) => res!.totalRecordCount!), getArtistRadio: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs2({ query: { @@ -996,12 +962,7 @@ export const SubsonicController: InternalControllerEndpoint = { } return res.body.similarSongs2.song.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ); }, getDownloadUrl: (args) => { @@ -1015,7 +976,7 @@ export const SubsonicController: InternalControllerEndpoint = { '&c=Feishin' ); }, - getFolder: async ({ apiClientProps, context, query }) => { + getFolder: async ({ apiClientProps, query }) => { const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc'; const isRootFolderId = query.id === '0'; @@ -1048,14 +1009,7 @@ export const SubsonicController: InternalControllerEndpoint = { }); } - let folders = items.map((item) => - ssNormalize.folder( - item, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ); + let folders = items.map((item) => ssNormalize.folder(item, apiClientProps.server)); folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]); @@ -1083,12 +1037,7 @@ export const SubsonicController: InternalControllerEndpoint = { throw new Error('Failed to get folder'); } - const folder = ssNormalize.folder( - directoryRes.body.directory, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ); + const folder = ssNormalize.folder(directoryRes.body.directory, apiClientProps.server); let filteredFolders = folder.children?.folders || []; let filteredSongs = folder.children?.songs || []; @@ -1148,9 +1097,15 @@ export const SubsonicController: InternalControllerEndpoint = { } switch (query.sortBy) { + case GenreListSort.ALBUM_COUNT: + results = orderBy(results, [(v) => v.albumCount], [sortOrder]); + break; case GenreListSort.NAME: results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); break; + case GenreListSort.SONG_COUNT: + results = orderBy(results, [(v) => v.songCount], [sortOrder]); + break; default: break; } @@ -1281,7 +1236,12 @@ export const SubsonicController: InternalControllerEndpoint = { return results.length; }, - getPlaylistSongList: async ({ apiClientProps, context, query }) => { + getPlaylistSongIds: async (args) => + SubsonicController.getPlaylistSongList(args).then((result) => ({ + ...result, + items: result.items.map((song) => song.id), + })), + getPlaylistSongList: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).getPlaylist({ query: { id: query.id, @@ -1294,13 +1254,7 @@ export const SubsonicController: InternalControllerEndpoint = { const items = res.body.playlist.entry?.map((song, index) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - index, - ), + ssNormalize.song(song, apiClientProps.server, index), ) || []; return { @@ -1309,7 +1263,7 @@ export const SubsonicController: InternalControllerEndpoint = { totalRecordCount: items.length, }; }, - getPlayQueue: async ({ apiClientProps, context }) => { + getPlayQueue: async ({ apiClientProps }) => { if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) { const res = await ssApiClient(apiClientProps).getPlayQueueByIndex(); @@ -1324,15 +1278,7 @@ export const SubsonicController: InternalControllerEndpoint = { changed: changed ?? '', changedBy: changedBy ?? '', currentIndex: currentIndex ?? 0, - entry: - entry?.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ) || [], + entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [], positionMs: position ?? 0, username: username ?? '', }; @@ -1349,22 +1295,14 @@ export const SubsonicController: InternalControllerEndpoint = { changed, changedBy, currentIndex: current ? entry.findIndex((item) => item.id === current) : 0, - entry: - entry?.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ) || [], + entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [], positionMs: position ?? 0, username, }; } }, getRandomSongList: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getRandomSongList({ query: { @@ -1382,12 +1320,7 @@ export const SubsonicController: InternalControllerEndpoint = { const results = res.body.randomSongs?.song || []; const normalizedResults = results.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ); return { @@ -1455,7 +1388,7 @@ export const SubsonicController: InternalControllerEndpoint = { } if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { - features.lyricsMultipleStructured = [1]; + features.lyricsMultipleStructured = subsonicFeatures[SubsonicExtensions.SONG_LYRICS]; } if (subsonicFeatures[SubsonicExtensions.FORM_POST]) { @@ -1469,11 +1402,27 @@ export const SubsonicController: InternalControllerEndpoint = { if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) { features.reportPlayback = [1]; } + try { + const jukeboxStatus = await ssApiClient(apiClientProps).jukeboxControl({ + query: { action: 'status' }, + }); + + if (jukeboxStatus.status === 200 && !(jukeboxStatus.body as any)?.error) { + features[ServerFeature.JUKEBOX] = [1]; + } else { + console.log( + 'Jukebox endpoint returned an error payload:', + (jukeboxStatus.body as any)?.error, + ); + } + } catch (error) { + console.log('Jukebox is not supported by this server:', error); + } return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; }, getSimilarSongs: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getSimilarSongs({ query: { @@ -1492,21 +1441,14 @@ export const SubsonicController: InternalControllerEndpoint = { return res.body.similarSongs.song.reduce((acc, song) => { if (song.id !== query.songId) { - acc.push( - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ); + acc.push(ssNormalize.song(song, apiClientProps.server)); } return acc; }, []); }, getSongDetail: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).getSong({ query: { @@ -1518,14 +1460,9 @@ export const SubsonicController: InternalControllerEndpoint = { throw new Error('Failed to get song detail'); } - return ssNormalize.song( - res.body.song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ); + return ssNormalize.song(res.body.song, apiClientProps.server); }, - getSongList: async ({ apiClientProps, context, query }) => { + getSongList: async ({ apiClientProps, query }) => { const fromAlbumPromises: Promise>[] = []; const artistDetailPromises: Promise>[] = []; @@ -1550,12 +1487,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { items: res.body.searchResult3?.song?.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ) || [], startIndex: query.startIndex, totalRecordCount: null, @@ -1579,15 +1511,7 @@ export const SubsonicController: InternalControllerEndpoint = { const results = res.body.songsByGenre?.song || []; return { - items: - results.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ) || [], + items: results.map((song) => ssNormalize.song(song, apiClientProps.server)) || [], startIndex: 0, totalRecordCount: null, }; @@ -1606,12 +1530,7 @@ export const SubsonicController: InternalControllerEndpoint = { let allResults = (res.body.starred?.song || []).map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ) || []; const filterArtistIds = query.albumArtistIds || query.artistIds; @@ -1696,15 +1615,7 @@ export const SubsonicController: InternalControllerEndpoint = { } return { - items: - results.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), - ) || [], + items: results.map((song) => ssNormalize.song(song, apiClientProps.server)) || [], startIndex: 0, totalRecordCount: results.length, }; @@ -1730,12 +1641,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { items: res.body.searchResult3?.song?.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ) || [], startIndex: 0, totalRecordCount: null, @@ -2062,9 +1968,16 @@ export const SubsonicController: InternalControllerEndpoint = { }, getStructuredLyrics: async (args) => { const { apiClientProps, query } = args; + const server = apiClientProps.server; + const supportsEnhancedLyrics = hasFeatureWithVersion( + server, + ServerFeature.LYRICS_MULTIPLE_STRUCTURED, + 2, + ); const res = await ssApiClient(apiClientProps).getStructuredLyrics({ query: { + enhanced: supportsEnhancedLyrics ? true : undefined, id: query.songId, }, }); @@ -2079,31 +1992,12 @@ export const SubsonicController: InternalControllerEndpoint = { return []; } - return lyrics.map((lyric) => { - const baseLyric = { - artist: lyric.displayArtist || '', - lang: lyric.lang, - name: lyric.displayTitle || '', - remote: false, - source: apiClientProps.server?.name || 'music server', - }; + const source = apiClientProps.server?.name || 'music server'; - if (lyric.synced) { - return { - ...baseLyric, - lyrics: lyric.line.map((line) => [line.start!, line.value]), - synced: true, - }; - } - return { - ...baseLyric, - lyrics: lyric.line.map((line) => [line.value]).join('\n'), - synced: false, - }; - }); + return lyrics.map((lyric) => mapStructuredLyric(lyric, source)); }, getTopSongs: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const type = query.type === 'personal' ? 'personal' : 'community'; @@ -2121,12 +2015,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { items: (res.body.topSongs?.song || []).map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ), startIndex: 0, totalRecordCount: res.body.topSongs?.song?.length || 0, @@ -2135,7 +2024,6 @@ export const SubsonicController: InternalControllerEndpoint = { const res = await SubsonicController.getSongList({ apiClientProps, - context, query: { artistIds: [query.artistId], sortBy: SongListSort.PLAY_COUNT, @@ -2175,6 +2063,25 @@ export const SubsonicController: InternalControllerEndpoint = { name: res.body.user.username, }; }, + jukeboxControl: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).jukeboxControl({ + query: { + action: query.action, + gain: query.gain, + id: query.id, + index: query.index, + offset: query.offset, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to control jukebox'); + } + + return res.body; + }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { @@ -2190,7 +2097,7 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, replacePlaylist: async (args) => { - const { apiClientProps, body, context, query } = args; + const { apiClientProps, body, query } = args; // 1. Fetch existing songs from the playlist const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({ @@ -2205,12 +2112,7 @@ export const SubsonicController: InternalControllerEndpoint = { const existingSongs = existingSongsRes.body.playlist.entry?.map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ) || []; // 2. Get playlist detail to get the name @@ -2279,7 +2181,10 @@ export const SubsonicController: InternalControllerEndpoint = { if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) { const res = await ssApiClient(apiClientProps).savePlayQueueByIndex({ query: { - currentIndex: query.currentIndex !== undefined ? query.currentIndex : undefined, + currentIndex: + query.currentIndex !== undefined && query.currentIndex < query.songs.length + ? Math.max(0, query.currentIndex) + : undefined, id: query.songs, position: query.positionMs, }, @@ -2331,7 +2236,7 @@ export const SubsonicController: InternalControllerEndpoint = { mediaId: query.id, mediaType: query.mediaType, playbackRate: query.playbackRate, - positionMs: query.position ?? 0, + positionMs: Math.round(query.position ?? 0), }; const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => { @@ -2388,7 +2293,7 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, search: async (args) => { - const { apiClientProps, context, query } = args; + const { apiClientProps, query } = args; const res = await ssApiClient(apiClientProps).search3({ query: { @@ -2412,20 +2317,10 @@ export const SubsonicController: InternalControllerEndpoint = { ssNormalize.albumArtist(artist, apiClientProps.server), ), albums: (res.body.searchResult3?.album || []).map((album) => - ssNormalize.album( - album, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + ssNormalize.album(album, apiClientProps.server), ), songs: (res.body.searchResult3?.song || []).map((song) => - ssNormalize.song( - song, - apiClientProps.server, - context?.pathReplace, - context?.pathReplaceWith, - ), + ssNormalize.song(song, apiClientProps.server), ), }; }, diff --git a/src/renderer/api/subsonic/subsonic-structured-lyrics.ts b/src/renderer/api/subsonic/subsonic-structured-lyrics.ts new file mode 100644 index 000000000..c25ab921d --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-structured-lyrics.ts @@ -0,0 +1,136 @@ +import { z } from 'zod'; + +import { sliceUtf8Bytes } from '/@/renderer/utils/utf8-byte-slice'; +import { ssType } from '/@/shared/api/subsonic/subsonic-types'; +import { + LyricAgent, + LyricsKind, + StructuredLyric, + SyncedCueLine, + SyncedWordCue, + SynchronizedLyricLine, +} from '/@/shared/types/domain-types'; + +type ApiStructuredLyric = NonNullable< + z.infer['lyricsList'] +>['structuredLyrics'] extends Array | undefined + ? T + : never; + +const getCueTrailingGap = ( + lineValue: string, + byteEnd: number, + nextByteStart: number | undefined, +): string => { + if (nextByteStart === undefined || nextByteStart <= byteEnd + 1) { + return ''; + } + + return sliceUtf8Bytes(lineValue, byteEnd + 1, nextByteStart - 1); +}; + +const mapCueWords = ( + apiCueLine: NonNullable[number], +): SyncedWordCue[] => { + if (!apiCueLine.cue?.length) { + return []; + } + + return apiCueLine.cue.map((cue, cueIndex) => { + const nextCue = apiCueLine.cue?.[cueIndex + 1]; + const wordText = cue.value || sliceUtf8Bytes(apiCueLine.value, cue.byteStart, cue.byteEnd); + const trailingGap = getCueTrailingGap(apiCueLine.value, cue.byteEnd, nextCue?.byteStart); + + return { + endMs: cue.end, + startMs: cue.start, + text: wordText + trailingGap, + }; + }); +}; + +const mapCueLine = ( + apiCueLine: NonNullable[number], +): SyncedCueLine => ({ + agentId: apiCueLine.agentId, + endMs: apiCueLine.end, + index: apiCueLine.index, + startMs: apiCueLine.start, + value: apiCueLine.value, + words: mapCueWords(apiCueLine), +}); + +const attachCueLines = ( + lines: SynchronizedLyricLine[], + apiCueLines: NonNullable, +): SynchronizedLyricLine[] => { + const cueLinesByIndex = new Map(); + + for (const apiCueLine of apiCueLines) { + const mappedCueLine = mapCueLine(apiCueLine); + const existing = cueLinesByIndex.get(apiCueLine.index) ?? []; + existing.push(mappedCueLine); + cueLinesByIndex.set(apiCueLine.index, existing); + } + + return lines.map((line, index) => { + const cueLines = cueLinesByIndex.get(index); + + if (!cueLines?.length) { + return line; + } + + return { + ...line, + cueLines, + }; + }); +}; + +const mapAgents = (agents: ApiStructuredLyric['agents']): LyricAgent[] | undefined => { + if (!agents?.length) { + return undefined; + } + + return agents.map((agent) => ({ + id: agent.id, + name: agent.name, + role: agent.role, + })); +}; + +export const mapStructuredLyric = (lyric: ApiStructuredLyric, source: string): StructuredLyric => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + offsetMs: lyric.offset ?? 0, + remote: false, + source, + }; + + if (lyric.synced) { + let lines: SynchronizedLyricLine[] = lyric.line.map((line) => ({ + startMs: line.start ?? 0, + text: line.value, + })); + + if (lyric.cueLine?.length) { + lines = attachCueLines(lines, lyric.cueLine); + } + + return { + ...baseLyric, + agents: mapAgents(lyric.agents), + kind: (lyric.kind ?? 'main') as LyricsKind, + lyrics: lines, + synced: true, + }; + } + + return { + ...baseLyric, + lyrics: lyric.line.map((line) => line.value).join('\n'), + synced: false, + }; +}; diff --git a/src/renderer/components/item-card/item-card.module.css b/src/renderer/components/item-card/item-card.module.css index 026212d79..43138bded 100644 --- a/src/renderer/components/item-card/item-card.module.css +++ b/src/renderer/components/item-card/item-card.module.css @@ -223,3 +223,11 @@ .row.muted.compact { color: var(--theme-colors-foreground); } + +.row.artists-row { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + white-space: normal; +} diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 7c3addab9..723907390 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -1154,6 +1154,7 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] artists={data.albumArtists} linkProps={{ fw: 400, isMuted: true }} rootTextProps={{ + className: styles.artistsRow, fw: 400, isMuted: type === 'compact' ? false : true, size: 'sm', @@ -1406,6 +1407,7 @@ const ItemCardRow = memo( // All rows except the first one (index 0) should be muted const isMuted = index > 0 || row.isMuted; + const isArtistRow = row.id === 'albumArtists' || row.id === 'artists'; const formattedContent = useMemo(() => { if (!data) { @@ -1432,6 +1434,7 @@ const ItemCardRow = memo( return ( (-1); const currentVisibleRangeRef = useRef(null); - const [isRefetching, setIsRefetching] = useState(false); const refetchPromiseRef = useRef>(null); const previousDataQueryKeyRef = useRef(''); const isRefetchingRef = useRef(false); @@ -213,7 +212,6 @@ export const useItemListInfiniteLoader = ({ const countQueryKey = listCountQuery.queryKey; // Set refetching state and create a promise to suspend - setIsRefetching(true); const refetchPromise = (async () => { try { // Reset the loaded pages @@ -239,7 +237,6 @@ export const useItemListInfiniteLoader = ({ // Fetch the first page after count is refetched await fetchPage(pageToFetch); } finally { - setIsRefetching(false); isRefetchingRef.current = false; refetchPromiseRef.current = null; } @@ -248,7 +245,6 @@ export const useItemListInfiniteLoader = ({ refetchPromiseRef.current = refetchPromise; refetchPromise.catch(() => { - setIsRefetching(false); isRefetchingRef.current = false; refetchPromiseRef.current = null; }); @@ -266,7 +262,7 @@ export const useItemListInfiniteLoader = ({ }); // Suspend if refetching - if (isRefetching && refetchPromiseRef.current) { + if (isRefetchingRef.current && refetchPromiseRef.current) { throw refetchPromiseRef.current; } @@ -494,10 +490,20 @@ export const useItemListInfiniteLoader = ({ .map(([, v]) => v); }, [data]); + const getLoadedItems = useCallback(() => { + const cacheData = queryClient.getQueryData(dataQueryKey); + const map = cacheData?.dataMap; + if (!map || map.size === 0) return []; + return Array.from(map.entries()) + .sort(([a], [b]) => a - b) + .map(([, v]) => v); + }, [dataQueryKey, queryClient]); + return { dataVersion: (data as any).version ?? 0, getItem, getItemIndex, + getLoadedItems, itemCount, loadedItems, onRangeChanged, diff --git a/src/renderer/components/item-list/helpers/use-list-hotkeys.ts b/src/renderer/components/item-list/helpers/use-list-hotkeys.ts index d9e60cbc9..fa6c638a3 100644 --- a/src/renderer/components/item-list/helpers/use-list-hotkeys.ts +++ b/src/renderer/components/item-list/helpers/use-list-hotkeys.ts @@ -1,3 +1,4 @@ +import { type RefObject, useRef } from 'react'; import { useNavigate } from 'react-router'; import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; @@ -13,12 +14,14 @@ import { Play } from '/@/shared/types/types'; export const useListHotkeys = ({ controls, + focusContainerRef, focused, internalState, itemType, onShowPlayingSong, }: { controls: ItemControls; + focusContainerRef?: RefObject; focused: boolean; internalState: ItemListStateActions; itemType: LibraryItem; @@ -27,6 +30,19 @@ export const useListHotkeys = ({ const { bindings } = useHotkeySettings(); const playButtonBehavior = usePlayButtonBehavior(); const navigate = useNavigate(); + const focusedRef = useRef(focused); + focusedRef.current = focused; + const internalStateRef = useRef(internalState); + internalStateRef.current = internalState; + + const isListFocused = () => { + const container = focusContainerRef?.current; + if (container) { + return container.contains(document.activeElement); + } + + return focusedRef.current; + }; // Helper to check if item has required properties const hasRequiredStateItemProperties = ( @@ -48,19 +64,20 @@ export const useListHotkeys = ({ [ 'mod+a', () => { - if (focused) { - if (internalState.isAllSelected()) { - internalState.deselectAll(); - } else { - internalState.selectAll(); - } + if (!isListFocused()) return; + + const state = internalStateRef.current; + if (state.isAllSelected()) { + state.deselectAll(); + } else { + state.selectAll(); } }, ], [ bindings.listPlayDefault.hotkey, () => { - if (!focused) return; + if (!isListFocused()) return; const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); if (validSelected.length === 0) return; @@ -73,7 +90,7 @@ export const useListHotkeys = ({ [ bindings.listPlayNow.hotkey, () => { - if (!focused) return; + if (!isListFocused()) return; const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); if (validSelected.length === 0) return; @@ -85,7 +102,7 @@ export const useListHotkeys = ({ [ bindings.listPlayNext.hotkey, () => { - if (!focused) return; + if (!isListFocused()) return; const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); if (validSelected.length === 0) return; @@ -97,7 +114,7 @@ export const useListHotkeys = ({ [ bindings.listPlayLast.hotkey, () => { - if (!focused) return; + if (!isListFocused()) return; const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); if (validSelected.length === 0) return; @@ -109,7 +126,7 @@ export const useListHotkeys = ({ [ bindings.listNavigateToPage.hotkey, () => { - if (!focused) return; + if (!isListFocused()) return; const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); if (validSelected.length === 0) return; diff --git a/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx index df244597d..adbe2f28c 100644 --- a/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx +++ b/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx @@ -1,3 +1,6 @@ import { ItemDetailListCellProps } from './types'; -export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> ; +import { resolveSongPath } from '/@/renderer/utils/resolve-song-path'; + +export const PathColumn = ({ song }: ItemDetailListCellProps) => + resolveSongPath(song.path) ?? <> ; diff --git a/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx index 3e09fb567..8a78a0fe7 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx +++ b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx @@ -1463,6 +1463,7 @@ export const ItemDetailList = ({ useListHotkeys({ controls, + focusContainerRef: containerRef, focused, internalState, itemType: LibraryItem.SONG, diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 0fc9430b7..ca977cd00 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -343,6 +343,7 @@ export interface ItemGridListProps { gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; getItem?: (index: number) => ItemCardProps['data']; getItemIndex?: (rowId: string) => number | undefined; + getLoadedItems?: () => unknown[]; getRowId?: ((item: unknown) => string) | string; initialTop?: { to: number; @@ -372,6 +373,7 @@ const BaseItemGridList = ({ gap = 'sm', getItem, getItemIndex, + getLoadedItems, getRowId, initialTop, itemCount, @@ -401,9 +403,18 @@ const BaseItemGridList = ({ [data, getItem], ); + const dataRef = useRef(data); + const getLoadedItemsRef = useRef(getLoadedItems); + dataRef.current = data; + getLoadedItemsRef.current = getLoadedItems; + const getDataFn = useCallback(() => { - return data; - }, [data]); + const loadedItems = getLoadedItemsRef.current?.(); + if (loadedItems?.length) { + return loadedItems; + } + return dataRef.current ?? []; + }, []); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); @@ -782,6 +793,7 @@ const BaseItemGridList = ({ useListHotkeys({ controls, + focusContainerRef: rootRef, focused, internalState, itemType, diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.module.css b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css new file mode 100644 index 000000000..a276bd72c --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css @@ -0,0 +1,10 @@ +.controls { + display: flex; + gap: var(--theme-spacing-xs); + align-items: center; + min-height: 22px; +} + +.hidden { + visibility: hidden; +} diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.tsx b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx new file mode 100644 index 000000000..e8c10d35e --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx @@ -0,0 +1,114 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import styles from './album-group-controls.module.css'; + +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite'; +import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; +import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; +import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; +import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; +import { useShowRatings } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Rating } from '/@/shared/components/rating/rating'; +import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; + +const isRatingSupported = (serverType: ServerType | undefined) => + serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC; + +const useAlbumGroupAlbum = (albumId: string | undefined, serverId: string | undefined) => { + const enabled = !!albumId && !!serverId && !albumId.startsWith('dummy/'); + + return useQuery({ + ...albumQueries.detail({ query: { id: albumId! }, serverId: serverId! }), + enabled, + staleTime: 5 * 60 * 1000, + }); +}; + +interface AlbumGroupControlsProps { + albumId: string | undefined; + isGroupHovered: boolean; + serverId: string | undefined; + serverType: ServerType | undefined; +} + +export const AlbumGroupControls = ({ + albumId, + isGroupHovered, + serverId, + serverType, +}: AlbumGroupControlsProps) => { + const showRatingsSetting = useShowRatings(); + const detailQuery = useAlbumGroupAlbum(albumId, serverId); + const setFavorite = useSetFavorite(); + const setRating = useSetRating(); + + const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); + const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); + const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; + const isMutatingRating = useIsMutatingRating(); + + const album = detailQuery.data; + const showRating = showRatingsSetting && isRatingSupported(serverType ?? album?._serverType); + + const handleFavorite = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + if (!album) return; + + setFavorite(album._serverId, [album.id], LibraryItem.ALBUM, !album.userFavorite); + }, + [album, setFavorite], + ); + + const handleRating = useCallback( + (rating: number) => { + if (!album) return; + + if (album.userRating === rating) { + return setRating(album._serverId, [album.id], LibraryItem.ALBUM, 0); + } + + return setRating(album._serverId, [album.id], LibraryItem.ALBUM, rating); + }, + [album, setRating], + ); + + if (!album) { + return null; + } + + return ( +
+ { + event.stopPropagation(); + event.preventDefault(); + }} + size="xs" + variant="subtle" + /> + {showRating && ( + + )} +
+ ); +}; diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.module.css b/src/renderer/components/item-list/item-table-list/album-group-header.module.css index 90203d8b2..16acaffec 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.module.css +++ b/src/renderer/components/item-list/item-table-list/album-group-header.module.css @@ -1,6 +1,7 @@ .container { display: flex; gap: var(--theme-spacing-sm); + align-items: flex-start; width: 100%; height: 100%; padding: 0 var(--theme-spacing-xs); @@ -12,22 +13,62 @@ flex-shrink: 0; height: 100%; aspect-ratio: 1; - padding-top: calc(var(--theme-spacing-xs) * 0.5); + overflow: hidden; + border-radius: var(--theme-radius-md); + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 5; + width: 100%; + height: 100%; + pointer-events: none; + content: ''; + background-color: rgb(0 0 0); + opacity: 0; + transition: all 0.2s ease-in-out; + } + + &:hover::before { + @mixin dark { + opacity: 0.7; + } + + @mixin light { + opacity: 0.5; + } + } } .info { display: flex; flex-direction: column; + gap: 0; + align-content: start; min-width: 0; padding: 0; - overflow: hidden; } -.album-name { - font-size: var(--theme-font-size-sm); +.info.enlarged-image { + padding-top: var(--theme-spacing-xs); } -.artist-name { +.album-title { + font-weight: 500; + overflow-wrap: anywhere; + white-space: normal; +} + +.metadata-row { font-size: var(--theme-font-size-xs); - opacity: 0.7; + color: var(--theme-colors-foreground-muted); + overflow-wrap: anywhere; + white-space: normal; +} + +.controls-row { + flex-shrink: 0; + min-height: 22px; + margin-top: var(--theme-spacing-xxs); } diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.tsx b/src/renderer/components/item-list/item-table-list/album-group-header.tsx index 9ca91f9f8..39221ecf1 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.tsx +++ b/src/renderer/components/item-list/item-table-list/album-group-header.tsx @@ -1,7 +1,16 @@ -import { ReactElement, useState } from 'react'; +import clsx from 'clsx'; +import { ReactElement, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { generatePath, Link } from 'react-router'; import imageColumnStyles from '../item-detail-list/columns/image-column.module.css'; +import { AlbumGroupControls } from './album-group-controls'; import styles from './album-group-header.module.css'; +import { + AlbumGroupMetadata, + AlbumGroupTextSize, + renderAlbumGroupMetadataItem, +} from './album-group-metadata'; import { TableItemSize } from './item-table-list'; import { ItemImage } from '/@/renderer/components/item-image/item-image'; @@ -10,39 +19,137 @@ import { LONG_PRESS_PLAY_BEHAVIOR, PlayTooltip, } from '/@/renderer/features/shared/components/play-button-group'; -import { usePlayButtonBehavior } from '/@/renderer/store'; +import { AppRoute } from '/@/renderer/router/routes'; +import { + useAlbumGroupImageSize, + useAlbumGroupItems, + useAlbumGroupShowFavoriteRating, + usePlayButtonBehavior, +} from '/@/renderer/store'; +import { Text } from '/@/shared/components/text/text'; import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; interface AlbumGroupHeaderProps { + groupKey?: string; groupRowCount?: number; + metadata: AlbumGroupMetadata; onPlay?: (playType: Play) => void; - size?: 'compact' | 'large' | 'normal'; + setAlbumGroupContentHeight?: (groupKey: string, height: number) => void; + size?: AlbumGroupTextSize; song: Song | undefined; + storedContentHeight?: number; } export const AlbumGroupHeader = ({ + groupKey, groupRowCount, + metadata, onPlay, + setAlbumGroupContentHeight, size = 'normal', song, + storedContentHeight, }: AlbumGroupHeaderProps): ReactElement => { + const { t } = useTranslation(); + const albumGroupItems = useAlbumGroupItems(); + const showFavoriteRating = useAlbumGroupShowFavoriteRating(); const [isHovered, setIsHovered] = useState(false); + const [resolved, setResolved] = useState( + null, + ); const playButtonBehavior = usePlayButtonBehavior(); + const albumImageSize = useAlbumGroupImageSize(); const rowHeight = { compact: TableItemSize.COMPACT, large: TableItemSize.LARGE, normal: TableItemSize.DEFAULT, }[size]; - const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined; + + const albumPath = song?.albumId + ? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: song.albumId }) + : null; + + const metadataRows = useMemo(() => { + return albumGroupItems + .filter((item) => !item.disabled) + .map((item) => ({ + content: renderAlbumGroupMetadataItem(item.id, song, metadata, t), + id: item.id, + })) + .filter((item) => item.content != null); + }, [albumGroupItems, metadata, song, t]); + + // The album group spans the combined row height, but when the image is + // enlarged the group's last row is grown so the total reaches the img size. + const infoHeight = + groupRowCount !== undefined + ? albumImageSize > 0 + ? Math.max(albumImageSize, groupRowCount * rowHeight) + : groupRowCount * rowHeight + : undefined; + + // Ignore resolved height from a previous (larger) group span so minHeight + // cannot keep scrollHeight measurement stuck after a split/shrink. + const resolvedInfoHeight = + resolved && infoHeight !== undefined && resolved.forInfoHeight === infoHeight + ? resolved.height + : undefined; + + const imageContainerStyle = + albumImageSize > 0 + ? { + aspectRatio: 'auto', + height: `${albumImageSize}px`, + paddingBottom: 'var(--theme-spacing-xs)', + paddingTop: 'var(--theme-spacing-xs)', + position: 'relative' as const, + width: `${albumImageSize}px`, + zIndex: 1, + } + : undefined; + + const infoRef = useRef(null); + + useLayoutEffect(() => { + const infoEl = infoRef.current; + if (!infoEl) return; + + const measure = () => { + const contentHeight = infoEl.scrollHeight; + const resolvedHeight = Math.max(infoHeight ?? 0, contentHeight); + + if (infoHeight !== undefined) { + setResolved({ forInfoHeight: infoHeight, height: resolvedHeight }); + } + + if (groupKey !== undefined && setAlbumGroupContentHeight) { + setAlbumGroupContentHeight(groupKey, contentHeight); + } + }; + + measure(); + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(infoEl); + + return () => resizeObserver.disconnect(); + }, [ + groupKey, + groupRowCount, + infoHeight, + metadataRows.length, + setAlbumGroupContentHeight, + storedContentHeight, + ]); return ( -
-
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
)}
-
-
{song?.album ?? ''}
-
{song?.albumArtistName ?? ''}
+
0 && styles.enlargedImage)} + ref={infoRef} + style={{ minHeight: resolvedInfoHeight ?? infoHeight }} + > + {song?.album && + (song.albumId && albumPath ? ( + + {song.album} + + ) : ( + + {song.album} + + ))} + {metadataRows.map((row) => ( +
{row.content}
+ ))} + {showFavoriteRating && ( +
+ +
+ )}
); diff --git a/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx b/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx new file mode 100644 index 000000000..f7021c414 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx @@ -0,0 +1,188 @@ +import { TFunction } from 'i18next'; +import { Fragment, ReactNode } from 'react'; +import { generatePath, Link } from 'react-router'; + +import styles from './album-group-header.module.css'; + +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; +import { AppRoute } from '/@/renderer/router/routes'; +import { AlbumGroupItem } from '/@/renderer/store'; +import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils'; +import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; +import { Text } from '/@/shared/components/text/text'; +import { Genre, Song } from '/@/shared/types/domain-types'; + +export type AlbumGroupMetadata = { + duration: number; + genres: Genre[]; + releaseDate: Song['releaseDate']; + releaseType: null | string; + releaseYear: Song['releaseYear']; + size: number; + songCount: number; +}; + +export type AlbumGroupTextSize = 'compact' | 'large' | 'normal'; + +const metadataTextProps = { + isMuted: true, + isNoSelect: true, + size: 'xs' as const, +}; + +const albumGroupArtistProps = { + linkProps: { ...JOINED_ARTISTS_MUTED_PROPS.linkProps, size: 'xs' as const }, + rootTextProps: { ...JOINED_ARTISTS_MUTED_PROPS.rootTextProps, size: 'xs' as const }, +}; + +const MetadataTextContainer = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export const computeAlbumGroupMetadata = ( + songs: Song[], + songCount: number, + t: TFunction, +): AlbumGroupMetadata => { + const genreMap = new Map(); + let duration = 0; + let size = 0; + + for (const song of songs) { + duration += song.duration ?? 0; + size += song.size ?? 0; + + for (const genre of song.genres ?? []) { + if (!genreMap.has(genre.id)) { + genreMap.set(genre.id, genre); + } + } + } + + const firstSong = songs[0]; + const releaseTypes = firstSong?.tags?.releasetype; + const releaseType = releaseTypes?.length + ? (normalizeReleaseTypes(releaseTypes, t)[0] ?? null) + : null; + + return { + duration, + genres: Array.from(genreMap.values()), + releaseDate: firstSong?.releaseDate ?? null, + releaseType, + releaseYear: firstSong?.releaseYear ?? null, + size, + songCount, + }; +}; + +const formatReleaseDate = (metadata: AlbumGroupMetadata): null | string => { + if (metadata.releaseDate) { + return formatPartialIsoDateUTC(metadata.releaseDate); + } + + return null; +}; + +export const renderAlbumGroupMetadataItem = ( + itemId: `${AlbumGroupItem}`, + song: Song | undefined, + metadata: AlbumGroupMetadata, + t: TFunction, +): null | ReactNode => { + switch (itemId) { + case AlbumGroupItem.ALBUM_ARTISTS: + if (!song?.albumArtistName && !(song?.albumArtists?.length ?? 0)) { + return null; + } + + return ( +
+ +
+ ); + + case AlbumGroupItem.DURATION: + return metadata.duration > 0 ? ( + + {formatDurationString(metadata.duration)} + + ) : null; + + case AlbumGroupItem.GENRES: + if (metadata.genres.length === 0) { + return null; + } + + return ( +
+ {metadata.genres.map((genre, index) => ( + + + {genre.name} + + {index < metadata.genres.length - 1 && ', '} + + ))} +
+ ); + + case AlbumGroupItem.RELEASE_DATE: { + const releaseDate = formatReleaseDate(metadata); + return releaseDate ? ( + + {releaseDate} + + ) : null; + } + + case AlbumGroupItem.RELEASE_TYPE: + return metadata.releaseType ? ( + + {metadata.releaseType} + + ) : null; + + case AlbumGroupItem.RELEASE_YEAR: + return metadata.releaseYear != null && metadata.releaseYear > 0 ? ( + + {metadata.releaseYear} + + ) : null; + + case AlbumGroupItem.SIZE: + return metadata.size > 0 ? ( + + {formatSizeString(metadata.size)} + + ) : null; + + case AlbumGroupItem.SONG_COUNT: + return metadata.songCount > 0 ? ( + + + {t('entity.trackWithCount', { count: metadata.songCount })} + + + ) : null; + + default: + return null; + } +}; diff --git a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx index 9cf308322..aecbcdb02 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx @@ -1,7 +1,10 @@ import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { AlbumGroupHeader } from '/@/renderer/components/item-list/item-table-list/album-group-header'; +import { computeAlbumGroupMetadata } from '/@/renderer/components/item-list/item-table-list/album-group-metadata'; import { + getAlbumGroupHeightKey, isLastInAlbumGroup, ItemTableListInnerColumn, TableColumnContainer, @@ -10,6 +13,7 @@ import { Song } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { + const { t } = useTranslation(); const firstDataRow = props.enableHeader ? 1 : 0; const item = props.getRowItem?.(props.rowIndex) as null | Song | undefined; @@ -64,26 +68,49 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { ...(needsBorder ? { borderBottom: '1px solid var(--theme-colors-border)' } : {}), + // When the cover is enlarged it overflows down from the + // group's first row into these cells; let hover/click pass + // through to reach it. + ...((props.albumGroupImageSize ?? 0) > 0 + ? { pointerEvents: 'none' as const } + : {}), }} /> ); } let groupRowCount = 1; + const groupSongs: Song[] = [item]; const totalDataRows = props.data.length + firstDataRow; for (let idx = props.rowIndex + 1; idx < totalDataRows; idx++) { const nextItem = props.getRowItem?.(idx) as null | Song | undefined; if (!nextItem || nextItem.album !== item.album) break; groupRowCount++; + groupSongs.push(nextItem); } + const metadata = computeAlbumGroupMetadata(groupSongs, groupRowCount, t); + const groupHeightKey = getAlbumGroupHeightKey(item, groupRowCount); + const storedContentHeight = groupHeightKey + ? props.albumGroupContentHeights?.get(groupHeightKey) + : undefined; + return ( - + ); diff --git a/src/renderer/components/item-list/item-table-list/columns/path-column.tsx b/src/renderer/components/item-list/item-table-list/columns/path-column.tsx index 52f5ef289..a48f759a2 100644 --- a/src/renderer/components/item-list/item-table-list/columns/path-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/path-column.tsx @@ -4,15 +4,17 @@ import { ItemTableListInnerColumn, TableColumnTextContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { resolveSongPath } from '/@/renderer/utils/resolve-song-path'; export const PathColumn = (props: ItemTableListInnerColumn) => { const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; + const resolvedPath = typeof row === 'string' ? resolveSongPath(row) : null; - if (typeof row === 'string' && row) { + if (resolvedPath) { return ( - {row} + {resolvedPath} ); } diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css index 876c0c6f7..4225bd339 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css @@ -34,16 +34,29 @@ } .title { - display: inline-block; + display: block; width: 100%; + min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + --text-text-wrap: nowrap; } a.title { - width: auto; + width: 100%; + max-width: 100%; +} + +.track-subtitle { + font-size: var(--theme-font-size-sm); + color: var(--theme-colors-foreground-muted); +} + +.track-subtitle.active { + color: inherit; } diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index 63f34793e..42a71da18 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -150,7 +150,13 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { [styles.compact]: props.size === 'compact', })} > - + {item.name as string} @@ -309,23 +315,21 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => })} isNoSelect size="md" + truncate {...titleLinkProps} > {row.name as string} {song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && ( - {' ('} {song.trackSubtitle} {')'} - + )}
@@ -370,6 +374,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => className={styles.title} isNoSelect size="md" + truncate {...titleLinkProps} style={textStyles} > diff --git a/src/renderer/components/item-list/item-table-list/default-columns.ts b/src/renderer/components/item-list/item-table-list/default-columns.ts index cad4cd720..17a674a5a 100644 --- a/src/renderer/components/item-list/item-table-list/default-columns.ts +++ b/src/renderer/components/item-list/item-table-list/default-columns.ts @@ -20,7 +20,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [ label: i18n.t('table.config.label.albumGroup'), pinned: 'left', value: TableColumn.ALBUM_GROUP, - width: 200, + width: 240, }, { align: 'center', diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx index b8b89e73c..adcaa7bea 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx +++ b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx @@ -64,6 +64,7 @@ export const useItemDragDropState = { if (!item || !isDataRow) { return; @@ -248,10 +249,15 @@ export const useItemDragDropState = 0) { - playerContext.addToQueueByData(sourceItems, { - edge: args.edge, - uniqueId: droppedOnUniqueId, - }); + const sourcePlaylistId = args.source.metadata?.playlistId as + | string + | undefined; + playerContext.addToQueueByData( + sourceItems, + { edge: args.edge, uniqueId: droppedOnUniqueId }, + undefined, + sourcePlaylistId ?? null, + ); } break; } diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate.ts b/src/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate.ts index 3a110c3ae..a076724c7 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate.ts @@ -83,23 +83,46 @@ export const useRowInteractionDelegate = ({ let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null; let rafId: null | number = null; + const isExcludedFromDragBorder = (el: HTMLElement) => + el.hasAttribute('data-exclude-row-drag-border'); + const clearRow = (rowKey: string) => { root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => { const el = node as HTMLElement; el.removeAttribute('data-row-dragged-over'); el.removeAttribute('data-row-dragged-over-first'); + el.removeAttribute('data-exclude-row-drag-left'); }); }; const applyRow = (rowKey: string, edge: 'bottom' | 'top') => { - const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`); - nodes.forEach((node, idx) => { + const nodes = Array.from(root.querySelectorAll(`[data-row-index="${rowKey}"]`)); + const eligibleNodes = nodes.filter( + (node) => !isExcludedFromDragBorder(node as HTMLElement), + ); + const hasExcludedBeforeFirst = eligibleNodes.length < nodes.length; + + nodes.forEach((node) => { + if (!isExcludedFromDragBorder(node as HTMLElement)) return; + const el = node as HTMLElement; + el.removeAttribute('data-row-dragged-over'); + el.removeAttribute('data-row-dragged-over-first'); + el.removeAttribute('data-exclude-row-drag-left'); + }); + + eligibleNodes.forEach((node, idx) => { const el = node as HTMLElement; el.setAttribute('data-row-dragged-over', edge); if (idx === 0) { el.setAttribute('data-row-dragged-over-first', 'true'); + if (hasExcludedBeforeFirst) { + el.setAttribute('data-exclude-row-drag-left', 'true'); + } else { + el.removeAttribute('data-exclude-row-drag-left'); + } } else { el.removeAttribute('data-row-dragged-over-first'); + el.removeAttribute('data-exclude-row-drag-left'); } }); }; diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts index 9d304bec0..1f880ba93 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts @@ -4,11 +4,15 @@ import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/i import { ItemListHandle } from '/@/renderer/components/item-list/types'; interface UseTableImperativeHandleProps { + autoScrollToActiveRow: boolean; enableHeader: boolean; handleRef: React.RefObject; internalState: ItemListStateActions; ref?: React.Ref; - scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void; + scrollToTableIndex: ( + index: number, + options?: { align?: 'bottom' | 'center' | 'top'; followActiveRow?: boolean }, + ) => void; scrollToTableOffset: (offset: number) => void; } @@ -16,6 +20,7 @@ interface UseTableImperativeHandleProps { * Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state. */ export const useTableImperativeHandle = ({ + autoScrollToActiveRow, enableHeader, handleRef, internalState, @@ -27,13 +32,22 @@ export const useTableImperativeHandle = ({ () => ({ internalState, scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { - scrollToTableIndex(enableHeader ? index + 1 : index, options); + scrollToTableIndex(enableHeader ? index + 1 : index, { + ...options, + followActiveRow: autoScrollToActiveRow, + }); }, scrollToOffset: (offset: number) => { scrollToTableOffset(offset); }, }), - [enableHeader, internalState, scrollToTableIndex, scrollToTableOffset], + [ + autoScrollToActiveRow, + enableHeader, + internalState, + scrollToTableIndex, + scrollToTableOffset, + ], ); useImperativeHandle(ref, () => imperativeHandle); diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts index a543339a8..0fe0365df 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts @@ -4,37 +4,26 @@ import { ItemListStateActions, ItemListStateItemWithRequiredProperties, } from '/@/renderer/components/item-list/helpers/item-list-state'; -import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list'; -import { ItemControls } from '/@/renderer/components/item-list/types'; -import { PlayerContext } from '/@/renderer/features/player/context/player-context'; -import { LibraryItem } from '/@/shared/types/domain-types'; interface UseTableKeyboardNavigationProps { calculateScrollTopForIndex: (index: number) => number; - cellPadding: TableItemProps['cellPadding']; data: unknown[]; - DEFAULT_ROW_HEIGHT: number; enableHeader: boolean; enableSelection: boolean; extractRowId: (item: unknown) => string | undefined; getItem?: (index: number) => undefined | unknown; getItemIndex?: (rowId: string) => number | undefined; + getRowHeightAtIndex: (index: number) => number; getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null; hasRequiredStateItemProperties: ( item: unknown, ) => item is ItemListStateItemWithRequiredProperties; internalState: ItemListStateActions; itemCount?: number; - itemType: LibraryItem; - parsedColumns: TableItemProps['columns']; pinnedRightColumnCount: number; pinnedRightColumnRef: React.RefObject; - playerContext: PlayerContext; - rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined; rowRef: React.RefObject; scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void; - size: TableItemProps['size']; - tableId: string; } /** @@ -42,28 +31,21 @@ interface UseTableKeyboardNavigationProps { */ export const useTableKeyboardNavigation = ({ calculateScrollTopForIndex, - cellPadding, data, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, getItem, getItemIndex, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, }: UseTableKeyboardNavigationProps) => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -122,42 +104,7 @@ export const useTableKeyboardNavigation = ({ const viewportBottom = viewportTop + viewportHeight; const rowTop = calculateScrollTopForIndex(gridIndex); - const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex; - const mockCellProps: TableItemProps = { - cellPadding, - columns: parsedColumns, - controls: {} as ItemControls, - data: enableHeader ? [null] : [], - enableAlternateRowColors: false, - enableExpansion: false, - enableHeader, - enableHorizontalBorders: false, - enableRowHoverHighlight: false, - enableSelection, - enableVerticalBorders: false, - getRowHeight: () => DEFAULT_ROW_HEIGHT, - getRowItem: (rowIndex: number) => { - if (!getItem) return undefined; - if (enableHeader && rowIndex === 0) return null; - const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; - return getItem(dataIndex); - }, - internalState: {} as ItemListStateActions, - itemType, - playerContext, - size, - tableId, - }; - - let calculatedRowHeight: number; - if (typeof rowHeight === 'number') { - calculatedRowHeight = rowHeight; - } else if (typeof rowHeight === 'function') { - calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps); - } else { - calculatedRowHeight = DEFAULT_ROW_HEIGHT; - } - + const calculatedRowHeight = getRowHeightAtIndex(gridIndex); const rowBottom = rowTop + calculatedRowHeight; // Check if row is fully visible within viewport @@ -187,28 +134,21 @@ export const useTableKeyboardNavigation = ({ }, [ calculateScrollTopForIndex, - cellPadding, data, getItem, getItemIndex, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, ], ); diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts index 6597983c8..79103ed88 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts @@ -7,6 +7,26 @@ import { useEffect } from 'react'; import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; +const getPaneScrollViewport = (ref: React.RefObject) => + ref.current?.firstElementChild as HTMLDivElement | undefined; + +const getPaneElements = ({ + pinnedLeftColumnRef, + pinnedRightColumnRef, + pinnedRowRef, + rowRef, +}: { + pinnedLeftColumnRef: React.RefObject; + pinnedRightColumnRef: React.RefObject; + pinnedRowRef: React.RefObject; + rowRef: React.RefObject; +}) => ({ + header: getPaneScrollViewport(pinnedRowRef), + pinnedLeft: getPaneScrollViewport(pinnedLeftColumnRef), + pinnedRight: getPaneScrollViewport(pinnedRightColumnRef), + row: getPaneScrollViewport(rowRef), +}); + export const useTablePaneSync = ({ enableDrag, enableDragScroll, @@ -21,6 +41,7 @@ export const useTablePaneSync = ({ rowRef, scrollContainerRef, scrollShadowStore, + scrollSyncKey, }: { enableDrag: boolean | undefined; enableDragScroll: boolean | undefined; @@ -37,6 +58,7 @@ export const useTablePaneSync = ({ rowRef: React.RefObject; scrollContainerRef: React.RefObject; scrollShadowStore: TableScrollShadowStore; + scrollSyncKey: string; }) => { // Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist const [initialize, osInstance] = useOverlayScrollbars({ @@ -144,6 +166,7 @@ export const useTablePaneSync = ({ osInstance, pinnedRightColumnCount, scrollContainerRef, + scrollSyncKey, ]); useEffect(() => { @@ -235,222 +258,294 @@ export const useTablePaneSync = ({ osInstanceRightPinned, pinnedRightColumnCount, pinnedRightColumnRef, + scrollSyncKey, ]); useEffect(() => { - const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement; - const row = rowRef.current?.childNodes[0] as HTMLDivElement; - const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement; - const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement; + let disposed = false; + let cleanup: (() => void) | undefined; + let setupFrameId = 0; - if (!row) return; + const resolvePaneRefs = () => + getPaneElements({ + pinnedLeftColumnRef, + pinnedRightColumnRef, + pinnedRowRef, + rowRef, + }); - // Ensure all containers have the same height - const syncHeights = () => { - const rowHeight = row.scrollHeight; - let targetHeight = rowHeight; + const isVerticalScrollHostReady = ( + row: HTMLDivElement, + pinnedRight: HTMLDivElement | undefined, + ) => { + const verticalScrollHost = pinnedRightColumnCount > 0 ? pinnedRight : row; - if (pinnedLeft) { - const pinnedLeftHeight = pinnedLeft.scrollHeight; - targetHeight = Math.max(targetHeight, pinnedLeftHeight); + if (!verticalScrollHost) { + return false; } - if (pinnedRight) { - const pinnedRightHeight = pinnedRight.scrollHeight; - targetHeight = Math.max(targetHeight, pinnedRightHeight); + if (pinnedRightColumnCount > 0) { + return verticalScrollHost.scrollHeight > verticalScrollHost.clientHeight; } - if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) { - pinnedLeft.style.height = `${targetHeight}px`; - } - if (pinnedRight && pinnedRight.style.height !== `${targetHeight}px`) { - pinnedRight.style.height = `${targetHeight}px`; - } - if (row.style.height !== `${targetHeight}px`) { - row.style.height = `${targetHeight}px`; - } + return verticalScrollHost.scrollHeight > 0; }; - const timeoutId = setTimeout(syncHeights, 0); - - const activeElement = { element: null } as { element: HTMLDivElement | null }; - const scrollingElements = new Set(); - const scrollTimeouts = new Map(); - - const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { - activeElement.element = e.currentTarget as HTMLDivElement; - }; - const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => { - activeElement.element = e.currentTarget as HTMLDivElement; - }; - - const markElementAsScrolling = (element: HTMLDivElement) => { - scrollingElements.add(element); - - const existingTimeout = scrollTimeouts.get(element); - if (existingTimeout) { - clearTimeout(existingTimeout); + const setupScrollSync = () => { + if (disposed) { + return; } - const timeout = setTimeout(() => { - scrollingElements.delete(element); + cleanup?.(); - const hasRightPinnedColumns = pinnedRightColumnCount > 0; - const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row; + const { header, pinnedLeft, pinnedRight, row } = resolvePaneRefs(); - if (scrollElement && onScrollEndRef.current) { - onScrollEndRef.current( - scrollElement.scrollTop, - (handleRef.current?.internalState ?? - (undefined as any)) as ItemListStateActions, - ); + if (!row || !isVerticalScrollHostReady(row, pinnedRight)) { + setupFrameId = requestAnimationFrame(setupScrollSync); + return; + } + + const syncHeights = () => { + const panes = resolvePaneRefs(); + const syncRow = panes.row; + const syncPinnedLeft = panes.pinnedLeft; + const syncPinnedRight = panes.pinnedRight; + + if (!syncRow) { + return; } - scrollTimeouts.delete(element); - }, 150); + const rowHeight = syncRow.scrollHeight; + let targetHeight = rowHeight; - scrollTimeouts.set(element, timeout); - }; + if (syncPinnedLeft) { + targetHeight = Math.max(targetHeight, syncPinnedLeft.scrollHeight); + } - const syncScroll = (e: HTMLElementEventMap['scroll']) => { - const currentElement = e.currentTarget as HTMLDivElement; - markElementAsScrolling(currentElement); + if (syncPinnedRight) { + targetHeight = Math.max(targetHeight, syncPinnedRight.scrollHeight); + } - const shouldSync = - currentElement === activeElement.element || scrollingElements.has(currentElement); - if (!shouldSync) return; + if (targetHeight <= 0) { + return; + } - const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; - const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; - - const isScrolling = { - header: false, - pinnedLeft: false, - pinnedRight: false, - row: false, + if (syncPinnedLeft && syncPinnedLeft.style.height !== `${targetHeight}px`) { + syncPinnedLeft.style.height = `${targetHeight}px`; + } + if (syncPinnedRight && syncPinnedRight.style.height !== `${targetHeight}px`) { + syncPinnedRight.style.height = `${targetHeight}px`; + } + if (rowHeight > 0 && syncRow.style.height !== `${targetHeight}px`) { + syncRow.style.height = `${targetHeight}px`; + } }; - const hasRightPinnedColumns = pinnedRightColumnCount > 0; + const timeoutId = setTimeout(syncHeights, 0); - if (header && e.currentTarget === header && !isScrolling.row) { - isScrolling.row = true; - row.scrollTo({ behavior: 'instant', left: scrollLeft }); - isScrolling.row = false; - } + const activeElement = { element: null } as { element: HTMLDivElement | null }; + const scrollingElements = new Set(); + const scrollTimeouts = new Map(); - if ( - e.currentTarget === row && - !isScrolling.header && - !isScrolling.pinnedLeft && - !isScrolling.pinnedRight - ) { - if (header) { - isScrolling.header = true; - header.scrollTo({ behavior: 'instant', left: scrollLeft }); + const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { + activeElement.element = e.currentTarget as HTMLDivElement; + }; + const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => { + activeElement.element = e.currentTarget as HTMLDivElement; + }; + + const markElementAsScrolling = (element: HTMLDivElement) => { + scrollingElements.add(element); + + const existingTimeout = scrollTimeouts.get(element); + if (existingTimeout) { + clearTimeout(existingTimeout); } - if (hasRightPinnedColumns && pinnedRight) { - isScrolling.pinnedRight = true; - pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); - isScrolling.pinnedRight = false; - } else { - if (pinnedLeft) { - isScrolling.pinnedLeft = true; - pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop }); - } - if (pinnedRight) { - isScrolling.pinnedRight = true; - pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); - } - } - isScrolling.header = false; - isScrolling.pinnedLeft = false; - } - if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) { - if (hasRightPinnedColumns && pinnedRight) { - isScrolling.pinnedRight = true; - pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); - isScrolling.pinnedRight = false; - } else { + const timeout = setTimeout(() => { + scrollingElements.delete(element); + + const panes = resolvePaneRefs(); + const hasRightPinnedColumns = pinnedRightColumnCount > 0; + const scrollElement = + hasRightPinnedColumns && panes.pinnedRight ? panes.pinnedRight : panes.row; + + if (scrollElement && onScrollEndRef.current) { + onScrollEndRef.current( + scrollElement.scrollTop, + (handleRef.current?.internalState ?? + (undefined as any)) as ItemListStateActions, + ); + } + + scrollTimeouts.delete(element); + }, 150); + + scrollTimeouts.set(element, timeout); + }; + + const syncScroll = (e: HTMLElementEventMap['scroll']) => { + const currentElement = e.currentTarget as HTMLDivElement; + markElementAsScrolling(currentElement); + + const panes = resolvePaneRefs(); + const syncHeader = panes.header; + const syncRow = panes.row; + const syncPinnedLeft = panes.pinnedLeft; + const syncPinnedRight = panes.pinnedRight; + + if (!syncRow) { + return; + } + + const shouldSync = + currentElement === activeElement.element || + scrollingElements.has(currentElement); + + if (!shouldSync) return; + + const scrollTop = currentElement.scrollTop; + const scrollLeft = currentElement.scrollLeft; + + const isScrolling = { + header: false, + pinnedLeft: false, + pinnedRight: false, + row: false, + }; + + const hasRightPinnedColumns = pinnedRightColumnCount > 0; + + if (syncHeader && currentElement === syncHeader && !isScrolling.row) { isScrolling.row = true; - row.scrollTo({ behavior: 'instant', top: scrollTop }); + syncRow.scrollTo({ behavior: 'instant', left: scrollLeft }); isScrolling.row = false; } - } - if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) { - isScrolling.row = true; - row.scrollTo({ behavior: 'instant', top: scrollTop }); - isScrolling.row = false; - if (pinnedLeft) { - isScrolling.pinnedLeft = true; - pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop }); + if ( + currentElement === syncRow && + !isScrolling.header && + !isScrolling.pinnedLeft && + !isScrolling.pinnedRight + ) { + if (syncHeader) { + isScrolling.header = true; + syncHeader.scrollTo({ behavior: 'instant', left: scrollLeft }); + } + if (hasRightPinnedColumns && syncPinnedRight) { + isScrolling.pinnedRight = true; + syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); + isScrolling.pinnedRight = false; + } else { + if (syncPinnedLeft) { + isScrolling.pinnedLeft = true; + syncPinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop }); + } + if (syncPinnedRight) { + isScrolling.pinnedRight = true; + syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); + } + } + isScrolling.header = false; isScrolling.pinnedLeft = false; } - } - }; - if (header) { - header.addEventListener('pointermove', setActiveElement); - header.addEventListener('wheel', setActiveElementFromWheel); - header.addEventListener('scroll', syncScroll); - } - row.addEventListener('pointermove', setActiveElement); - row.addEventListener('wheel', setActiveElementFromWheel); - row.addEventListener('scroll', syncScroll); - if (pinnedLeft) { - pinnedLeft.addEventListener('pointermove', setActiveElement); - pinnedLeft.addEventListener('wheel', setActiveElementFromWheel); - pinnedLeft.addEventListener('scroll', syncScroll); - } - if (pinnedRight) { - pinnedRight.addEventListener('pointermove', setActiveElement); - pinnedRight.addEventListener('wheel', setActiveElementFromWheel); - pinnedRight.addEventListener('scroll', syncScroll); - } + if (syncPinnedLeft && currentElement === syncPinnedLeft && !isScrolling.row) { + if (hasRightPinnedColumns && syncPinnedRight) { + isScrolling.pinnedRight = true; + syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); + isScrolling.pinnedRight = false; + } else { + isScrolling.row = true; + syncRow.scrollTo({ behavior: 'instant', top: scrollTop }); + isScrolling.row = false; + } + } - let heightSyncDebounceTimeout: NodeJS.Timeout | null = null; - const resizeObserver = new ResizeObserver(() => { - if (heightSyncDebounceTimeout) { - clearTimeout(heightSyncDebounceTimeout); - } - heightSyncDebounceTimeout = setTimeout(() => { - syncHeights(); - }, 100); - }); - - resizeObserver.observe(row); - if (pinnedLeft) resizeObserver.observe(pinnedLeft); - if (pinnedRight) resizeObserver.observe(pinnedRight); - - return () => { - clearTimeout(timeoutId); - scrollTimeouts.forEach((timeout) => clearTimeout(timeout)); - scrollTimeouts.clear(); - scrollingElements.clear(); + if (syncPinnedRight && currentElement === syncPinnedRight && !isScrolling.row) { + isScrolling.row = true; + syncRow.scrollTo({ behavior: 'instant', top: scrollTop }); + isScrolling.row = false; + if (syncPinnedLeft) { + isScrolling.pinnedLeft = true; + syncPinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop }); + isScrolling.pinnedLeft = false; + } + } + }; if (header) { - header.removeEventListener('pointermove', setActiveElement); - header.removeEventListener('wheel', setActiveElementFromWheel); - header.removeEventListener('scroll', syncScroll); + header.addEventListener('pointermove', setActiveElement); + header.addEventListener('wheel', setActiveElementFromWheel); + header.addEventListener('scroll', syncScroll); } - row.removeEventListener('pointermove', setActiveElement); - row.removeEventListener('wheel', setActiveElementFromWheel); - row.removeEventListener('scroll', syncScroll); + row.addEventListener('pointermove', setActiveElement); + row.addEventListener('wheel', setActiveElementFromWheel); + row.addEventListener('scroll', syncScroll); if (pinnedLeft) { - pinnedLeft.removeEventListener('pointermove', setActiveElement); - pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel); - pinnedLeft.removeEventListener('scroll', syncScroll); + pinnedLeft.addEventListener('pointermove', setActiveElement); + pinnedLeft.addEventListener('wheel', setActiveElementFromWheel); + pinnedLeft.addEventListener('scroll', syncScroll); } if (pinnedRight) { - pinnedRight.removeEventListener('pointermove', setActiveElement); - pinnedRight.removeEventListener('wheel', setActiveElementFromWheel); - pinnedRight.removeEventListener('scroll', syncScroll); + pinnedRight.addEventListener('pointermove', setActiveElement); + pinnedRight.addEventListener('wheel', setActiveElementFromWheel); + pinnedRight.addEventListener('scroll', syncScroll); } - if (heightSyncDebounceTimeout) { - clearTimeout(heightSyncDebounceTimeout); - } - resizeObserver.disconnect(); + + let heightSyncDebounceTimeout: NodeJS.Timeout | null = null; + const resizeObserver = new ResizeObserver(() => { + if (heightSyncDebounceTimeout) { + clearTimeout(heightSyncDebounceTimeout); + } + heightSyncDebounceTimeout = setTimeout(() => { + syncHeights(); + }, 100); + }); + + resizeObserver.observe(row); + if (pinnedLeft) resizeObserver.observe(pinnedLeft); + if (pinnedRight) resizeObserver.observe(pinnedRight); + + cleanup = () => { + clearTimeout(timeoutId); + scrollTimeouts.forEach((timeout) => clearTimeout(timeout)); + scrollTimeouts.clear(); + scrollingElements.clear(); + + if (header) { + header.removeEventListener('pointermove', setActiveElement); + header.removeEventListener('wheel', setActiveElementFromWheel); + header.removeEventListener('scroll', syncScroll); + } + row.removeEventListener('pointermove', setActiveElement); + row.removeEventListener('wheel', setActiveElementFromWheel); + row.removeEventListener('scroll', syncScroll); + if (pinnedLeft) { + pinnedLeft.removeEventListener('pointermove', setActiveElement); + pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel); + pinnedLeft.removeEventListener('scroll', syncScroll); + } + if (pinnedRight) { + pinnedRight.removeEventListener('pointermove', setActiveElement); + pinnedRight.removeEventListener('wheel', setActiveElementFromWheel); + pinnedRight.removeEventListener('scroll', syncScroll); + } + if (heightSyncDebounceTimeout) { + clearTimeout(heightSyncDebounceTimeout); + } + resizeObserver.disconnect(); + }; + }; + + setupFrameId = requestAnimationFrame(() => { + setupFrameId = requestAnimationFrame(setupScrollSync); + }); + + return () => { + disposed = true; + cancelAnimationFrame(setupFrameId); + cleanup?.(); }; }, [ handleRef, @@ -461,6 +556,7 @@ export const useTablePaneSync = ({ pinnedRightColumnRef, pinnedRowRef, rowRef, + scrollSyncKey, ]); // Handle left and right shadow visibility based on horizontal scroll diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts index 6390cbecf..335667d30 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts @@ -1,101 +1,45 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { TableItemProps } from '../item-table-list'; -import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; -import { ItemControls } from '/@/renderer/components/item-list/types'; -import { PlayerContext } from '/@/renderer/features/player/context/player-context'; -import { LibraryItem } from '/@/shared/types/domain-types'; +export type TableScrollToIndexOptions = { + align?: 'bottom' | 'center' | 'top'; + followActiveRow?: boolean; +}; export const useTableScrollToIndex = ({ - cellPadding, - columns, - data, - enableAlternateRowColors, - enableExpansion, + albumGroupContentHeights, + autoScrollToActiveRow, enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, + getRowHeight, + hasAlbumGroupColumn, pinnedLeftColumnRef, pinnedRightColumnRef, - playerContext, - rowHeight, + pinnedRowCount, rowRef, - size, - tableId, + scrollCellProps, }: { - cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; - columns: TableItemProps['columns']; - data: unknown[]; - enableAlternateRowColors: boolean; - enableExpansion: boolean; + albumGroupContentHeights: Map; + autoScrollToActiveRow: boolean; enableHeader: boolean; - enableHorizontalBorders: boolean; - enableRowHoverHighlight: boolean; - enableSelection: boolean; - enableVerticalBorders: boolean; - itemType: LibraryItem; + getRowHeight: (index: number, cellProps: TableItemProps) => number; + hasAlbumGroupColumn: boolean; pinnedLeftColumnRef: React.RefObject; pinnedRightColumnRef: React.RefObject; - playerContext: PlayerContext; - rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined; + pinnedRowCount: number; rowRef: React.RefObject; - size: 'compact' | 'default' | 'large'; - tableId: string; + scrollCellProps: TableItemProps; }) => { - const DEFAULT_ROW_HEIGHT = useMemo(() => { - return size === 'compact' ? 40 : size === 'large' ? 88 : 64; - }, [size]); - - const mockCellPropsBase = useMemo( - () => ({ - cellPadding, - columns, - controls: {} as ItemControls, - data: enableHeader ? [null, ...data] : data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - getRowHeight: () => DEFAULT_ROW_HEIGHT, - internalState: {} as ItemListStateActions, - itemType, - playerContext, - size, - tableId, - }), - [ - DEFAULT_ROW_HEIGHT, - cellPadding, - columns, - data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, - playerContext, - size, - tableId, - ], - ); + const isProgrammaticScrollRef = useRef(false); + const userInterruptedFollowRef = useRef(false); + const pendingFollowScrollRef = useRef(null); const getRowHeightAtIndex = useCallback( - (index: number) => { - if (typeof rowHeight === 'number') return rowHeight; - if (typeof rowHeight === 'function') return rowHeight(index, mockCellPropsBase); - return DEFAULT_ROW_HEIGHT; - }, - [DEFAULT_ROW_HEIGHT, mockCellPropsBase, rowHeight], + (index: number) => getRowHeight(index, scrollCellProps), + [getRowHeight, scrollCellProps], ); const scrollToTableOffset = useCallback( @@ -110,6 +54,8 @@ export const useTableScrollToIndex = ({ const behavior = 'instant'; + isProgrammaticScrollRef.current = true; + if (mainContainer) { mainContainer.scrollTo({ behavior, top: offset }); } @@ -119,37 +65,38 @@ export const useTableScrollToIndex = ({ if (pinnedRightContainer) { pinnedRightContainer.scrollTo({ behavior, top: offset }); } + + requestAnimationFrame(() => { + isProgrammaticScrollRef.current = false; + }); }, [pinnedLeftColumnRef, pinnedRightColumnRef, rowRef], ); + const scrollRowOffset = enableHeader ? pinnedRowCount : 0; + const calculateScrollTopForIndex = useCallback( (index: number) => { - const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; let scrollTop = 0; - for (let i = 0; i < adjustedIndex; i++) { + for (let i = scrollRowOffset; i < index; i++) { scrollTop += getRowHeightAtIndex(i); } return scrollTop; }, - [enableHeader, getRowHeightAtIndex], + [getRowHeightAtIndex, scrollRowOffset], ); - const scrollToTableIndex = useCallback( - (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { + const applyScrollToIndex = useCallback( + (index: number, options?: TableScrollToIndexOptions) => { const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; if (!mainContainer) return; const viewportHeight = mainContainer.clientHeight; const align = options?.align || 'top'; - // Calculate the base scroll offset (top of the row) let offset = calculateScrollTopForIndex(index); - - // Calculate row height for the target index - const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; - const targetRowHeight = getRowHeightAtIndex(adjustedIndex); + const targetRowHeight = getRowHeightAtIndex(index); if (align === 'center') { offset = offset - viewportHeight / 2 + targetRowHeight / 2; @@ -160,22 +107,64 @@ export const useTableScrollToIndex = ({ offset = Math.max(0, offset); scrollToTableOffset(offset); }, - [ - calculateScrollTopForIndex, - enableHeader, - getRowHeightAtIndex, - rowRef, - scrollToTableOffset, - ], + [calculateScrollTopForIndex, getRowHeightAtIndex, rowRef, scrollToTableOffset], ); + const scrollToTableIndex = useCallback( + (index: number, options?: TableScrollToIndexOptions) => { + const shouldFollow = autoScrollToActiveRow && options?.followActiveRow; + if (shouldFollow) { + pendingFollowScrollRef.current = { index, options }; + userInterruptedFollowRef.current = false; + } + + applyScrollToIndex(index, options); + }, + [applyScrollToIndex, autoScrollToActiveRow], + ); + + useEffect(() => { + const viewport = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; + if (!viewport) return; + + const handleUserScroll = () => { + if (isProgrammaticScrollRef.current) return; + + userInterruptedFollowRef.current = true; + pendingFollowScrollRef.current = null; + }; + + viewport.addEventListener('scroll', handleUserScroll, { passive: true }); + + return () => viewport.removeEventListener('scroll', handleUserScroll); + }, [rowRef]); + + useEffect(() => { + if (!autoScrollToActiveRow || !hasAlbumGroupColumn) return; + + const pending = pendingFollowScrollRef.current; + if (!pending || userInterruptedFollowRef.current) return; + if (albumGroupContentHeights.size === 0) return; + + const timeout = window.setTimeout(() => { + if (userInterruptedFollowRef.current) return; + + const currentPending = pendingFollowScrollRef.current; + if (!currentPending) return; + + applyScrollToIndex(currentPending.index, currentPending.options); + }, 50); + + return () => window.clearTimeout(timeout); + }, [albumGroupContentHeights, applyScrollToIndex, autoScrollToActiveRow, hasAlbumGroupColumn]); + return useMemo( () => ({ calculateScrollTopForIndex, - DEFAULT_ROW_HEIGHT, + getRowHeightAtIndex, scrollToTableIndex, scrollToTableOffset, }), - [calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset], + [calculateScrollTopForIndex, getRowHeightAtIndex, scrollToTableIndex, scrollToTableOffset], ); }; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index 01cef746e..99db441bf 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -31,6 +31,11 @@ padding-left: 0; } +.container.no-vertical-padding { + padding-top: 0; + padding-bottom: 0; +} + .container.center { align-items: center; text-align: center; @@ -161,6 +166,11 @@ margin-left: 9999px; } +.container.data-row[data-row-dragged-over='top'][data-row-dragged-over-first='true'][data-exclude-row-drag-left='true']::after { + left: 0; + margin-left: 0; +} + .container.data-row[data-row-dragged-over='bottom']::after { position: absolute; right: 0; @@ -180,6 +190,11 @@ margin-left: 9999px; } +.container.data-row[data-row-dragged-over='bottom'][data-row-dragged-over-first='true'][data-exclude-row-drag-left='true']::after { + left: 0; + margin-left: 0; +} + .container.data-row > * { position: relative; z-index: 2; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index e0b37fe31..3991d7ded 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -57,7 +57,10 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column'; import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column'; import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; -import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { + TableItemProps, + TableItemSize, +} from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types'; import { Flex } from '/@/shared/components/flex/flex'; @@ -381,6 +384,84 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED]; +/** Stable key for album-group content heights (survives row moves; not row index). */ +export function getAlbumGroupHeightKey(item: unknown, groupRowCount?: number): string | undefined { + if (!item || typeof item !== 'object') return undefined; + + let itemKey: string | undefined; + if ('_uniqueId' in item && typeof (item as { _uniqueId?: unknown })._uniqueId === 'string') { + itemKey = (item as { _uniqueId: string })._uniqueId; + } else if ('id' in item && typeof (item as { id?: unknown }).id === 'string') { + itemKey = (item as { id: string }).id; + } + + if (!itemKey) return undefined; + if (groupRowCount === undefined) return itemKey; + return `${itemKey}:${groupRowCount}`; +} + +// Counts how many consecutive rows belong to the same album group as `rowIndex`. +export function getAlbumGroupRowCount( + rowIndex: number, + getRowItem: ((index: number) => unknown) | undefined, + enableHeader: boolean | undefined, + dataLength: number, +): number { + const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; + if (!item?.album) return 1; + + const firstDataRow = enableHeader ? 1 : 0; + const maxRow = enableHeader ? dataLength + 1 : dataLength; + + let start = rowIndex; + while (start > firstDataRow) { + const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string }; + if (!prevItem || prevItem.album !== item.album) break; + start--; + } + + let end = rowIndex; + while (end + 1 < maxRow) { + const nextItem = getRowItem?.(end + 1) as null | undefined | { album?: string }; + if (!nextItem || nextItem.album !== item.album) break; + end++; + } + + return end - start + 1; +} + +export function getAlbumGroupSpanHeight( + groupRowCount: number, + baseHeight: number, + albumGroupImageSize: number, + contentHeight = 0, +): number { + const rowSpanHeight = groupRowCount * baseHeight; + const imageSpanHeight = + albumGroupImageSize > 0 ? Math.max(albumGroupImageSize, rowSpanHeight) : rowSpanHeight; + + return Math.max(imageSpanHeight, contentHeight); +} + +export function getAlbumGroupStartRowIndex( + rowIndex: number, + getRowItem: ((index: number) => unknown) | undefined, + enableHeader: boolean | undefined, +): number { + const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; + if (!item?.album) return rowIndex; + + const firstDataRow = enableHeader ? 1 : 0; + let start = rowIndex; + while (start > firstDataRow) { + const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string }; + if (!prevItem || prevItem.album !== item.album) break; + start--; + } + + return start; +} + export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean { return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled); } @@ -402,6 +483,121 @@ export function isLastInAlbumGroup( return !nextItem || nextItem.album !== item.album; } +function baseRowHeightForSize(size: ItemTableListColumn['size']): number { + if (size === 'compact') return TableItemSize.COMPACT; + if (size === 'large') return TableItemSize.LARGE; + return TableItemSize.DEFAULT; +} + +// Wraps a clamped cell with the spacer that fills the reserved (grown) height +// below it. The spacer carries the group's bottom/right borders so they align +// across all columns. +function ClampedCell({ + cell, + clampHeight, + outerStyle, + showHorizontalBorder, + showVerticalBorder, +}: { + cell: ReactElement; + clampHeight: null | number; + outerStyle?: CSSProperties; + showHorizontalBorder: boolean; + showVerticalBorder: boolean; +}): ReactElement { + const grownHeight = typeof outerStyle?.height === 'number' ? outerStyle.height : 0; + const spacerHeight = clampHeight !== null ? grownHeight - clampHeight : 0; + + if (clampHeight === null || spacerHeight <= 0) return cell; + + return ( +
+ {cell} +
+
+ ); +} + +// When an enlarged album image extends past the album group's combined row +// height, the last row of the group is grown (in getRowHeight) to reserve the +// leftover space. This returns the standard (un-grown) height to clamp that +// row's non-album cells to, so the track content + hover/selection stay at +// standard height and the reserved space below is left empty (uniform +// background) for the overflowing album image. +function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | number { + const albumImageSize = props.albumGroupImageSize ?? 0; + + if (albumImageSize <= 0) return null; + if (props.type === TableColumn.ALBUM_GROUP) return null; + if (!isAlbumGroupingActive(props.columns)) return null; + + const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; + if (!isDataRow) return null; + + const item = props.getRowItem?.(props.rowIndex) as null | undefined | { album?: string }; + if (!item?.album) return null; + + if ( + !isLastInAlbumGroup(props.rowIndex, props.getRowItem, props.enableHeader, props.data.length) + ) { + return null; + } + + const baseHeight = baseRowHeightForSize(props.size); + const groupRowCount = getAlbumGroupRowCount( + props.rowIndex, + props.getRowItem, + props.enableHeader, + props.data.length, + ); + const groupStartRowIndex = getAlbumGroupStartRowIndex( + props.rowIndex, + props.getRowItem, + props.enableHeader, + ); + const groupStartItem = props.getRowItem?.(groupStartRowIndex); + const groupHeightKey = getAlbumGroupHeightKey(groupStartItem, groupRowCount); + const contentHeight = + (groupHeightKey ? props.albumGroupContentHeights?.get(groupHeightKey) : undefined) ?? 0; + const totalGroupHeight = getAlbumGroupSpanHeight( + groupRowCount, + baseHeight, + albumImageSize, + contentHeight, + ); + + // Only clamp when the row was actually grown to fit the image or wrapped text. + if (totalGroupHeight <= groupRowCount * baseHeight) return null; + + return baseHeight; +} + +function showHorizontalBorderFor(props: ItemTableListInnerColumn, isLastRow: boolean): boolean { + if (!props.enableHorizontalBorders || !props.enableHeader || props.rowIndex <= 0) { + return false; + } + if (isAlbumGroupingActive(props.columns)) { + return isLastInAlbumGroup( + props.rowIndex, + props.getRowItem, + !!props.enableHeader, + props.data.length, + ); + } + return props.rowIndex === 1 || !isLastRow; +} + export const TableColumnTextContainer = ( props: ItemTableListColumn & { children: React.ReactNode; @@ -425,6 +621,7 @@ export const TableColumnTextContainer = ( ? props.internalState.extractRowId(item) : undefined; const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); + const clampHeight = getAlbumGroupClampHeight(props); const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); @@ -507,7 +704,10 @@ export const TableColumnTextContainer = ( } }; - return ( + const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow); + const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn; + + const cell = (
0 && - (isAlbumGroupingActive(props.columns) - ? isLastInAlbumGroup( - props.rowIndex, - props.getRowItem, - !!props.enableHeader, - props.data.length, - ) - : props.rowIndex === 1 || !isLastRow), - [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, + // When clamped, the bottom border is drawn on the spacer below + // instead. + [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null, + [styles.withVerticalBorder]: showVerticalBorder, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleClick} onContextMenu={handleContextMenu} ref={mergedRef} - style={props.style} + style={clampHeight !== null ? { height: clampHeight } : props.style} >
); + + return ( + + ); }; export const TableColumnContainer = ( @@ -586,6 +787,7 @@ export const TableColumnContainer = ( ? props.internalState.extractRowId(item) : undefined; const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); + const clampHeight = getAlbumGroupClampHeight(props); const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); @@ -668,7 +870,10 @@ export const TableColumnContainer = ( } }; - return ( + const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow); + const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn; + + const cell = (
0, [styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingMd]: props.cellPadding === 'md', [styles.paddingSm]: props.cellPadding === 'sm', @@ -694,29 +901,34 @@ export const TableColumnContainer = ( props.type !== TableColumn.ALBUM_GROUP, [styles.rowSelected]: isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP, - [styles.withHorizontalBorder]: - props.enableHorizontalBorders && - props.enableHeader && - props.rowIndex > 0 && - (isAlbumGroupingActive(props.columns) - ? isLastInAlbumGroup( - props.rowIndex, - props.getRowItem, - !!props.enableHeader, - props.data.length, - ) - : props.rowIndex === 1 || !isLastRow), - [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, + // When clamped, the bottom border is drawn on the spacer below instead. + [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null, + [styles.withVerticalBorder]: showVerticalBorder, })} + data-exclude-row-drag-border={props.type === TableColumn.ALBUM_GROUP ? true : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleClick} onContextMenu={handleContextMenu} ref={mergedRef} - style={{ ...props.containerStyle, ...props.style }} + style={ + clampHeight !== null + ? { ...props.containerStyle, height: clampHeight } + : { ...props.containerStyle, ...props.style } + } > {props.children}
); + + return ( + + ); }; interface ColumnResizeHandleProps { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index fcb29d5b7..264693733 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -44,7 +44,14 @@ import { useTableKeyboardNavigation } from '/@/renderer/components/item-list/ite import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync'; import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model'; import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index'; -import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { + getAlbumGroupHeightKey, + getAlbumGroupRowCount, + getAlbumGroupSpanHeight, + getAlbumGroupStartRowIndex, + isLastInAlbumGroup, + ItemTableListColumn, +} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { ItemTableListColumnResizeLiveProvider, type ItemTableListConfig, @@ -66,7 +73,7 @@ import { ItemTableListColumnConfig, } from '/@/renderer/components/item-list/types'; import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context'; -import { usePlayerStore } from '/@/renderer/store'; +import { useAlbumGroupImageSize, usePlayerStore } from '/@/renderer/store'; import { animationProps } from '/@/shared/components/animations/animation-props'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; @@ -170,6 +177,7 @@ const ItemTableScrollShadowRight = memo(function ItemTableScrollShadowRight({ ItemTableScrollShadowRight.displayName = 'ItemTableScrollShadowRight'; interface VirtualizedTableGridProps { + albumGroupContentHeights: Map; calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; data: unknown[]; @@ -187,12 +195,14 @@ interface VirtualizedTableGridProps { pinnedRowCount: number; pinnedRowRef: React.RefObject; scrollShadowStore: TableScrollShadowStore; + setAlbumGroupContentHeight: (groupKey: string, height: number) => void; tableConfig: ItemTableListConfig; totalColumnCount: number; totalRowCount: number; } const VirtualizedTableGrid = ({ + albumGroupContentHeights, calculatedColumnWidths, CellComponent, data, @@ -210,11 +220,13 @@ const VirtualizedTableGrid = ({ pinnedRowCount, pinnedRowRef, scrollShadowStore, + setAlbumGroupContentHeight, tableConfig, totalColumnCount, totalRowCount, }: VirtualizedTableGridProps) => { const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig; + const albumGroupImageSize = useAlbumGroupImageSize(); const hoverDelegateRef = useRef(null); useRowInteractionDelegate({ @@ -372,40 +384,15 @@ const VirtualizedTableGrid = ({ ], ); - const gridOnlyProps = useMemo( - () => ({ - calculatedColumnWidths, - data: dataWithGroups, - getAdjustedRowIndex, - getGroupRenderData, - getRowItem, - groupHeaderInfoByRowIndex, - hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), - pinnedLeftColumnCount, - pinnedLeftColumnWidths, - pinnedRightColumnCount, - pinnedRightColumnWidths, - }), - [ - calculatedColumnWidths, - dataWithGroups, - getRowItem, - getAdjustedRowIndex, - getGroupRenderData, - groupHeaderInfoByRowIndex, - parsedColumns, - pinnedLeftColumnCount, - pinnedLeftColumnWidths, - pinnedRightColumnCount, - pinnedRightColumnWidths, - ], - ); - const itemProps: TableItemProps = useMemo( () => ({ + albumGroupContentHeights, + albumGroupImageSize, + calculatedColumnWidths, cellPadding: tableConfig.cellPadding, columns: tableConfig.columns, controls: tableConfig.controls, + data: dataWithGroups, enableAlternateRowColors: tableConfig.enableAlternateRowColors, enableColumnReorder: tableConfig.enableColumnReorder, enableColumnResize: tableConfig.enableColumnResize, @@ -416,18 +403,43 @@ const VirtualizedTableGrid = ({ enableRowHoverHighlight: tableConfig.enableRowHoverHighlight, enableSelection: tableConfig.enableSelection, enableVerticalBorders: tableConfig.enableVerticalBorders, + getAdjustedRowIndex, + getGroupRenderData, getRowHeight: tableConfig.getRowHeight, + getRowItem, + groupHeaderInfoByRowIndex, groups: tableConfig.groups, + hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), internalState: tableConfig.internalState, itemType: tableConfig.itemType, + pinnedLeftColumnCount, + pinnedLeftColumnWidths, + pinnedRightColumnCount, + pinnedRightColumnWidths, playerContext: tableConfig.playerContext, playlistId: tableConfig.playlistId, + setAlbumGroupContentHeight, size: tableConfig.size, startRowIndex: tableConfig.startRowIndex, tableId: tableConfig.tableId, - ...gridOnlyProps, }), - [gridOnlyProps, tableConfig], + [ + albumGroupContentHeights, + albumGroupImageSize, + calculatedColumnWidths, + dataWithGroups, + getAdjustedRowIndex, + getGroupRenderData, + getRowItem, + groupHeaderInfoByRowIndex, + parsedColumns, + pinnedLeftColumnCount, + pinnedLeftColumnWidths, + pinnedRightColumnCount, + pinnedRightColumnWidths, + setAlbumGroupContentHeight, + tableConfig, + ], ); const pinnedLeftGridMinWidthPx = useMemo(() => { @@ -723,6 +735,8 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next prevProps.calculatedColumnWidths, nextProps.calculatedColumnWidths, ) && + prevProps.albumGroupContentHeights === nextProps.albumGroupContentHeights && + prevProps.setAlbumGroupContentHeight === nextProps.setAlbumGroupContentHeight && prevProps.tableConfig === nextProps.tableConfig && prevProps.data === nextProps.data && prevProps.dataWithGroups === nextProps.dataWithGroups && @@ -760,6 +774,8 @@ export interface TableGroupHeader { export interface TableItemProps { adjustedRowIndexMap?: Map; + albumGroupContentHeights?: Map; + albumGroupImageSize?: number; calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; columns: ItemTableListColumnConfig[]; @@ -792,6 +808,7 @@ export interface TableItemProps { pinnedRightColumnWidths?: number[]; playerContext: PlayerContext; playlistId?: string; + setAlbumGroupContentHeight?: (groupKey: string, height: number) => void; size?: ItemTableListProps['size']; startRowIndex?: number; tableId: string; @@ -800,6 +817,7 @@ export interface TableItemProps { interface ItemTableListProps { activeRowId?: string; autoFitColumns?: boolean; + autoScrollToActiveRow?: boolean; CellComponent?: JSXElementConstructor>; cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; columns: ItemTableListColumnConfig[]; @@ -820,6 +838,7 @@ interface ItemTableListProps { enableVerticalBorders?: boolean; getItem?: (index: number) => undefined | unknown; getItemIndex?: (rowId: string) => number | undefined; + getLoadedItems?: () => unknown[]; getRowId?: ((item: unknown) => string) | string; groups?: TableGroupHeader[]; headerHeight?: number; @@ -1238,6 +1257,7 @@ ItemTableListStickyUI.displayName = 'ItemTableListStickyUI'; const BaseItemTableList = ({ activeRowId, autoFitColumns = false, + autoScrollToActiveRow = false, CellComponent = ItemTableListColumn, cellPadding = 'sm', columns, @@ -1257,6 +1277,7 @@ const BaseItemTableList = ({ enableVerticalBorders = false, getItem, getItemIndex, + getLoadedItems, getRowId, groups, headerHeight = 40, @@ -1275,7 +1296,25 @@ const BaseItemTableList = ({ }: ItemTableListProps) => { const { playlistId: routePlaylistId } = useParams() as { playlistId?: string }; const tableId = useId(); + const albumGroupImageSize = useAlbumGroupImageSize(); const baseItemCount = itemCount ?? data.length; + const [albumGroupContentHeights, setAlbumGroupContentHeights] = useState( + () => new Map(), + ); + + const setAlbumGroupContentHeight = useCallback((groupKey: string, height: number) => { + setAlbumGroupContentHeights((prev) => { + if (prev.get(groupKey) === height) return prev; + const next = new Map(prev); + next.set(groupKey, height); + return next; + }); + }, []); + + useEffect(() => { + setAlbumGroupContentHeights(new Map()); + }, [baseItemCount]); + const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0); @@ -1375,47 +1414,10 @@ const BaseItemTableList = ({ onScrollEndRef.current = onScrollEnd; }, [onScrollEnd]); - const { - calculateScrollTopForIndex, - DEFAULT_ROW_HEIGHT, - scrollToTableIndex, - scrollToTableOffset, - } = useTableScrollToIndex({ - cellPadding, - columns: parsedColumns, - data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, - pinnedLeftColumnRef, - pinnedRightColumnRef, - playerContext, - rowHeight, - rowRef, - size, - tableId, - }); - - useTablePaneSync({ - enableDrag, - enableDragScroll, - enableHeader, - handleRef, - onScrollEndRef, - pinnedLeftColumnCount, - pinnedLeftColumnRef, - pinnedRightColumnCount, - pinnedRightColumnRef, - pinnedRowRef, - rowRef, - scrollContainerRef, - scrollShadowStore, - }); + const hasAlbumGroupColumn = useMemo( + () => parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), + [parsedColumns], + ); const getRowHeight = useCallback( (index: number, cellProps: TableItemProps) => { @@ -1434,11 +1436,160 @@ const BaseItemTableList = ({ return headerHeight; } + // Grow the group's LAST row when the album image or wrapped album + // info (title + artists + controls) needs more than the standard + // combined row height. Other rows keep their standard height. + if ( + cellProps?.hasAlbumGroupColumn && + isLastInAlbumGroup( + index, + cellProps.getRowItem, + cellProps.enableHeader, + cellProps.data.length, + ) + ) { + const item = cellProps.getRowItem?.(index) as null | undefined | { album?: string }; + if (item?.album) { + const groupRowCount = getAlbumGroupRowCount( + index, + cellProps.getRowItem, + cellProps.enableHeader, + cellProps.data.length, + ); + const groupStartRowIndex = getAlbumGroupStartRowIndex( + index, + cellProps.getRowItem, + cellProps.enableHeader, + ); + const groupStartItem = cellProps.getRowItem?.(groupStartRowIndex); + const groupHeightKey = getAlbumGroupHeightKey(groupStartItem, groupRowCount); + const contentHeight = + (groupHeightKey + ? cellProps.albumGroupContentHeights?.get(groupHeightKey) + : undefined) ?? 0; + const totalGroupHeight = getAlbumGroupSpanHeight( + groupRowCount, + baseHeight, + albumGroupImageSize, + contentHeight, + ); + const lastRowHeight = totalGroupHeight - (groupRowCount - 1) * baseHeight; + if (lastRowHeight > baseHeight) { + return lastRowHeight; + } + } + } + return baseHeight; }, - [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], + [albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); + const scrollCellProps = useMemo( + () => ({ + albumGroupContentHeights, + albumGroupImageSize, + cellPadding, + columns: parsedColumns, + controls: {} as ItemControls, + data: dataWithGroups, + enableAlternateRowColors, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getRowHeight, + getRowItem: (rowIndex: number) => { + if (shouldUseAccessor && getItem) { + if (enableHeader && rowIndex === 0) { + return null; + } + + const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; + return getItem(dataIndex); + } + + return dataWithGroups[rowIndex]; + }, + hasAlbumGroupColumn, + internalState: {} as ItemListStateActions, + itemType, + playerContext, + size, + tableId, + }), + [ + albumGroupContentHeights, + albumGroupImageSize, + cellPadding, + dataWithGroups, + enableAlternateRowColors, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getItem, + getRowHeight, + hasAlbumGroupColumn, + itemType, + parsedColumns, + playerContext, + shouldUseAccessor, + size, + tableId, + ], + ); + + const { + calculateScrollTopForIndex, + getRowHeightAtIndex, + scrollToTableIndex, + scrollToTableOffset, + } = useTableScrollToIndex({ + albumGroupContentHeights, + autoScrollToActiveRow, + enableHeader, + getRowHeight, + hasAlbumGroupColumn, + pinnedLeftColumnRef, + pinnedRightColumnRef, + pinnedRowCount, + rowRef, + scrollCellProps, + }); + + const scrollSyncKey = useMemo( + () => + parsedColumns + .map( + (col) => + `${col.id}:${col.pinned ?? 'none'}:${col.width}:${col.isEnabled !== false}`, + ) + .join('|'), + [parsedColumns], + ); + + useTablePaneSync({ + enableDrag, + enableDragScroll, + enableHeader, + handleRef, + onScrollEndRef, + pinnedLeftColumnCount, + pinnedLeftColumnRef, + pinnedRightColumnCount, + pinnedRightColumnRef, + pinnedRowRef, + rowRef, + scrollContainerRef, + scrollShadowStore, + scrollSyncKey, + }); + // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) const getRowHeightWrapper = useCallback( (index: number) => { @@ -1461,9 +1612,18 @@ const BaseItemTableList = ({ [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); + const dataRef = useRef(data); + const getLoadedItemsRef = useRef(getLoadedItems); + dataRef.current = data; + getLoadedItemsRef.current = getLoadedItems; + const getDataFn = useCallback(() => { - return data; - }, [data]); + const loadedItems = getLoadedItemsRef.current?.(); + if (loadedItems?.length) { + return loadedItems; + } + return dataRef.current ?? []; + }, []); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); @@ -1489,28 +1649,21 @@ const BaseItemTableList = ({ const { handleKeyDown } = useTableKeyboardNavigation({ calculateScrollTopForIndex, - cellPadding, data, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, getItem, getItemIndex, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount: baseItemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, }); useTableInitialScroll({ @@ -1521,6 +1674,7 @@ const BaseItemTableList = ({ }); useTableImperativeHandle({ + autoScrollToActiveRow, enableHeader, handleRef, internalState, @@ -1609,6 +1763,7 @@ const BaseItemTableList = ({ useListHotkeys({ controls, + focusContainerRef: containerRef, focused, internalState, itemType, @@ -1726,6 +1881,7 @@ const BaseItemTableList = ({ totalColumnCount={totalColumnCount} /> ; +} + +// OverlayScrollbars initializes on the NativeScrollArea container and moves the +// content into a viewport child element; that child is what actually scrolls, +// so scrollTop must be read from and written to it rather than the container +// the ref points at. +const getScrollNode = (scrollRef: RefObject): HTMLElement | null => { + const node = scrollRef.current?.children[0]; + return node instanceof HTMLElement ? node : null; +}; + +// Persists vertical scroll offset for a NativeScrollArea, keyed by react-router +// location.key. Restores the saved offset only on POP navigation; PUSH/REPLACE +// continue to start at the top. +export const useNativeScrollPersist = ({ enabled, scrollRef }: UseNativeScrollPersistProps) => { + const location = useLocation(); + const navigationType = useNavigationType(); + const setOffset = useScrollStore((s) => s.setOffset); + const getOffset = useScrollStore((s) => s.getOffset); + + useLayoutEffect(() => { + const saved = getOffset(location.key); + if (!enabled || navigationType !== 'POP' || typeof saved !== 'number') { + return; + } + + const applyOffset = () => { + const node = getScrollNode(scrollRef); + if (node) { + node.scrollTop = saved; + } + }; + + applyOffset(); + const raf = requestAnimationFrame(applyOffset); + return () => cancelAnimationFrame(raf); + }, [enabled, getOffset, location.key, navigationType, scrollRef]); + + useEffect(() => { + const node = getScrollNode(scrollRef); + if (!enabled || !node) { + return; + } + + const handleScroll = () => { + setOffset(location.key, node.scrollTop); + }; + + node.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + node.removeEventListener('scroll', handleScroll); + }; + }, [enabled, location.key, scrollRef, setOffset]); +}; diff --git a/src/renderer/components/query-builder/query-builder-option.tsx b/src/renderer/components/query-builder/query-builder-option.tsx index 5865b2192..56896f811 100644 --- a/src/renderer/components/query-builder/query-builder-option.tsx +++ b/src/renderer/components/query-builder/query-builder-option.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Filters } from '/@/renderer/components/query-builder'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; @@ -102,19 +102,28 @@ const QueryValueInput = ({ const isDatePickerOperator = operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate'; + const BooleanSelectComponent = useMemo( + () => ( + - ); + return BooleanSelectComponent; case 'date': if (isDatePickerOperator && operator !== 'inTheRangeDate') { const dateValue = value ? parseDateValue(value) : null; diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index 6879595f0..875a81d96 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -13,6 +13,7 @@ export type EventMap = { MPV_RELOAD: MpvReloadEventPayload; PLAYER_PLAY: PlayerPlayEventPayload; PLAYER_REPEATED: PlayerRepeatedEventPayload; + PLAYER_STOP: PlayerStopEventPayload; PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload; @@ -54,6 +55,12 @@ export type PlayerRepeatedEventPayload = { index: number; }; +export type PlayerStopEventPayload = { + id?: string; + index?: number; + reset: boolean; +}; + export type PlaylistMoveEventPayload = { playlistId: string; sourceIds: string[]; diff --git a/src/renderer/features/action-required/utils/server-lock.ts b/src/renderer/features/action-required/utils/server-lock.ts new file mode 100644 index 000000000..294da9191 --- /dev/null +++ b/src/renderer/features/action-required/utils/server-lock.ts @@ -0,0 +1,34 @@ +import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; +import { ServerType } from '/@/shared/types/types'; + +export const normalizeServerUrl = (url: string) => url.replace(/\/$/, ''); + +export const findExistingServerLockServer = ( + serverList: Record, + configuredUrl: string, + serverType?: null | ServerType, +): ServerListItemWithCredential | undefined => { + const servers = Object.values(serverList); + + if (servers.length === 0) { + return undefined; + } + + const normalizedUrl = normalizeServerUrl(configuredUrl); + const byUrl = servers.find((server) => normalizeServerUrl(server.url) === normalizedUrl); + + if (byUrl) { + return byUrl; + } + + // Server lock allows only one server — reuse the existing entry even if the URL changed. + if (servers.length === 1) { + return servers[0]; + } + + if (serverType) { + return servers.find((server) => server.type === serverType); + } + + return undefined; +}; diff --git a/src/renderer/features/albums/components/album-list-infinite-grid.tsx b/src/renderer/features/albums/components/album-list-infinite-grid.tsx index 6346ebfd6..6e98f1fbf 100644 --- a/src/renderer/features/albums/components/album-list-infinite-grid.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-grid.tsx @@ -40,16 +40,23 @@ export const AlbumListInfiniteGrid = ({ const { pageKey } = useListContext(); - const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = - useItemListInfiniteLoader({ - eventKey: pageKey || ItemListKey.ALBUM, - itemsPerPage, - itemType: LibraryItem.ALBUM, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { + dataVersion, + getItem, + getItemIndex, + getLoadedItems, + itemCount, + loadedItems, + onRangeChanged, + } = useItemListInfiniteLoader({ + eventKey: pageKey || ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -67,6 +74,7 @@ export const AlbumListInfiniteGrid = ({ gap={gap} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/albums/components/album-list-infinite-table.tsx b/src/renderer/features/albums/components/album-list-infinite-table.tsx index b258b17a4..f6f49d193 100644 --- a/src/renderer/features/albums/components/album-list-infinite-table.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-table.tsx @@ -46,7 +46,7 @@ export const AlbumListInfiniteTable = ({ const listQueryFn = api.controller.getAlbumList; const { pageKey } = useListContext(); - const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + const { getItem, getItemIndex, getLoadedItems, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: pageKey || ItemListKey.ALBUM, itemsPerPage, @@ -83,6 +83,7 @@ export const AlbumListInfiniteTable = ({ enableVerticalBorders={enableVerticalBorders} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/artists/components/album-artist-list-infinite-grid.tsx b/src/renderer/features/artists/components/album-artist-list-infinite-grid.tsx index 85558a5be..76bc2b535 100644 --- a/src/renderer/features/artists/components/album-artist-list-infinite-grid.tsx +++ b/src/renderer/features/artists/components/album-artist-list-infinite-grid.tsx @@ -37,16 +37,23 @@ export const AlbumArtistListInfiniteGrid = ({ const listQueryFn = api.controller.getAlbumArtistList; - const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = - useItemListInfiniteLoader({ - eventKey: ItemListKey.ALBUM_ARTIST, - itemsPerPage, - itemType: LibraryItem.ALBUM_ARTIST, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { + dataVersion, + getItem, + getItemIndex, + getLoadedItems, + itemCount, + loadedItems, + onRangeChanged, + } = useItemListInfiniteLoader({ + eventKey: ItemListKey.ALBUM_ARTIST, + itemsPerPage, + itemType: LibraryItem.ALBUM_ARTIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -63,6 +70,7 @@ export const AlbumArtistListInfiniteGrid = ({ gap={gap} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/artists/components/album-artist-list-infinite-table.tsx b/src/renderer/features/artists/components/album-artist-list-infinite-table.tsx index 0304c1ec5..ccff2fa2f 100644 --- a/src/renderer/features/artists/components/album-artist-list-infinite-table.tsx +++ b/src/renderer/features/artists/components/album-artist-list-infinite-table.tsx @@ -44,7 +44,7 @@ export const AlbumArtistListInfiniteTable = ({ const listQueryFn = api.controller.getAlbumArtistList; - const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + const { getItem, getItemIndex, getLoadedItems, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: ItemListKey.ALBUM_ARTIST, itemsPerPage, @@ -82,6 +82,7 @@ export const AlbumArtistListInfiniteTable = ({ enableVerticalBorders={enableVerticalBorders} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/artists/components/artist-list-infinite-grid.tsx b/src/renderer/features/artists/components/artist-list-infinite-grid.tsx index a8e235992..9d758f1e9 100644 --- a/src/renderer/features/artists/components/artist-list-infinite-grid.tsx +++ b/src/renderer/features/artists/components/artist-list-infinite-grid.tsx @@ -37,16 +37,23 @@ export const ArtistListInfiniteGrid = ({ const listQueryFn = api.controller.getArtistList; - const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = - useItemListInfiniteLoader({ - eventKey: ItemListKey.ARTIST, - itemsPerPage, - itemType: LibraryItem.ARTIST, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { + dataVersion, + getItem, + getItemIndex, + getLoadedItems, + itemCount, + loadedItems, + onRangeChanged, + } = useItemListInfiniteLoader({ + eventKey: ItemListKey.ARTIST, + itemsPerPage, + itemType: LibraryItem.ARTIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -63,6 +70,7 @@ export const ArtistListInfiniteGrid = ({ gap={gap} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/artists/components/artist-list-infinite-table.tsx b/src/renderer/features/artists/components/artist-list-infinite-table.tsx index 8c9979701..d95ecb118 100644 --- a/src/renderer/features/artists/components/artist-list-infinite-table.tsx +++ b/src/renderer/features/artists/components/artist-list-infinite-table.tsx @@ -44,7 +44,7 @@ export const ArtistListInfiniteTable = ({ const listQueryFn = api.controller.getArtistList; - const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + const { getItem, getItemIndex, getLoadedItems, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: ItemListKey.ARTIST, itemsPerPage, @@ -82,6 +82,7 @@ export const ArtistListInfiniteTable = ({ enableVerticalBorders={enableVerticalBorders} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts index ba904f104..432bdab6c 100644 --- a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts @@ -7,11 +7,14 @@ import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-o import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { setMultipleSearchParams } from '/@/renderer/utils/query-params'; import { runInUrlTransition } from '/@/renderer/utils/url-transition'; -import { AlbumArtistListSort } from '/@/shared/types/domain-types'; +import { AlbumArtistListSort, ArtistListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; export const useAlbumArtistListFilters = () => { - const { sortBy } = useSortByFilter(null, ItemListKey.ALBUM_ARTIST); + const { sortBy } = useSortByFilter( + ArtistListSort.NAME, + ItemListKey.ALBUM_ARTIST, + ); const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST); diff --git a/src/renderer/features/artists/hooks/use-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-artist-list-filters.ts index 0c9c56758..c03f28ab0 100644 --- a/src/renderer/features/artists/hooks/use-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-artist-list-filters.ts @@ -7,7 +7,7 @@ import { ArtistListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; export const useArtistListFilters = () => { - const { sortBy } = useSortByFilter(null, ItemListKey.ARTIST); + const { sortBy } = useSortByFilter(ArtistListSort.NAME, ItemListKey.ARTIST); const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST); diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx index 874f4de6f..36d53bfc5 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; +import { useNativeScrollPersist } from '/@/renderer/components/native-scroll-area/use-native-scroll-persist'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content'; @@ -28,6 +29,8 @@ const AlbumArtistDetailRouteContent = () => { const serverId = useCurrentServerId(); const { artistBackground, artistBackgroundBlur } = useArtistBackground(); + useNativeScrollPersist({ enabled: true, scrollRef: scrollAreaRef }); + const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; artistId?: string; diff --git a/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx index 5926bedcc..2134f476c 100644 --- a/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx +++ b/src/renderer/features/context-menu/actions/add-to-playlist-action.tsx @@ -211,11 +211,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp let songsToAdd: string[] = allSongIds; if (skipDuplicates) { - const queryKey = queryKeys.playlists.songList(serverId, playlistId); + const queryKey = queryKeys.playlists.songListIds(serverId, playlistId); const playlistSongsRes = await queryClient.fetchQuery({ queryFn: ({ signal }) => { - return api.controller.getPlaylistSongList({ + return api.controller.getPlaylistSongIds({ apiClientProps: { serverId, signal, @@ -228,7 +228,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp queryKey, }); - const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id); + const playlistSongIds = playlistSongsRes?.items; const uniqueSongIds: string[] = []; for (const songId of allSongIds) { diff --git a/src/renderer/features/context-menu/actions/show-in-file-explorer-action.tsx b/src/renderer/features/context-menu/actions/show-in-file-explorer-action.tsx index aaf80fcec..32cf6a3f7 100644 --- a/src/renderer/features/context-menu/actions/show-in-file-explorer-action.tsx +++ b/src/renderer/features/context-menu/actions/show-in-file-explorer-action.tsx @@ -2,6 +2,7 @@ import isElectron from 'is-electron'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { resolveSongPath } from '/@/renderer/utils/resolve-song-path'; import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; import { toast } from '/@/shared/components/toast/toast'; import { QueueSong, Song } from '/@/shared/types/domain-types'; @@ -21,12 +22,13 @@ export const ShowInFileExplorerAction = ({ items }: ShowInFileExplorerActionProp } const firstItem = items[0]; - if (!firstItem?.path) { + const resolvedPath = resolveSongPath(firstItem?.path); + if (!resolvedPath) { return; } try { - await utils.openItem(firstItem.path); + await utils.openItem(resolvedPath); } catch (error) { toast.error({ message: (error as Error).message, diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index ba7af726a..38628396e 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -81,6 +81,15 @@ export const useDiscordRpc = () => { privateModeRef.current = privateMode; }, [privateMode]); + // If the component is unmounted while RPC is enabled, quit RPC + useEffect(() => { + return () => { + if (previousEnabledRef.current) { + discordRpc?.quit(); + } + }; + }, []); + const setActivity = useCallback( async (current: ActivityState, trigger: ActivityTrigger) => { const song = current[0]; diff --git a/src/renderer/features/genres/components/genre-list-infinite-grid.tsx b/src/renderer/features/genres/components/genre-list-infinite-grid.tsx index fd64e6313..b95d72e5a 100644 --- a/src/renderer/features/genres/components/genre-list-infinite-grid.tsx +++ b/src/renderer/features/genres/components/genre-list-infinite-grid.tsx @@ -37,16 +37,23 @@ export const GenreListInfiniteGrid = ({ const listQueryFn = api.controller.getGenreList; - const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = - useItemListInfiniteLoader({ - eventKey: ItemListKey.GENRE, - itemsPerPage, - itemType: LibraryItem.GENRE, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { + dataVersion, + getItem, + getItemIndex, + getLoadedItems, + itemCount, + loadedItems, + onRangeChanged, + } = useItemListInfiniteLoader({ + eventKey: ItemListKey.GENRE, + itemsPerPage, + itemType: LibraryItem.GENRE, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -63,6 +70,7 @@ export const GenreListInfiniteGrid = ({ gap={gap} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/genres/components/genre-list-infinite-table.tsx b/src/renderer/features/genres/components/genre-list-infinite-table.tsx index 3aee5f58b..2f8ab24f3 100644 --- a/src/renderer/features/genres/components/genre-list-infinite-table.tsx +++ b/src/renderer/features/genres/components/genre-list-infinite-table.tsx @@ -44,7 +44,7 @@ export const GenreListInfiniteTable = ({ const listQueryFn = api.controller.getGenreList; - const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + const { getItem, getItemIndex, getLoadedItems, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: ItemListKey.GENRE, itemsPerPage, @@ -82,6 +82,7 @@ export const GenreListInfiniteTable = ({ enableVerticalBorders={enableVerticalBorders} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/genres/hooks/use-genre-list-filters.ts b/src/renderer/features/genres/hooks/use-genre-list-filters.ts index d329275b2..83bd9694c 100644 --- a/src/renderer/features/genres/hooks/use-genre-list-filters.ts +++ b/src/renderer/features/genres/hooks/use-genre-list-filters.ts @@ -6,7 +6,7 @@ import { GenreListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; export const useGenreListFilters = () => { - const { sortBy } = useSortByFilter(null, ItemListKey.GENRE); + const { sortBy } = useSortByFilter(GenreListSort.NAME, ItemListKey.GENRE); const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE); diff --git a/src/renderer/features/item-details/components/song-path.tsx b/src/renderer/features/item-details/components/song-path.tsx index 68bfc9c45..436a2b9c4 100644 --- a/src/renderer/features/item-details/components/song-path.tsx +++ b/src/renderer/features/item-details/components/song-path.tsx @@ -1,6 +1,7 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; +import { useResolvedSongPath } from '/@/renderer/utils/resolve-song-path'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { CopyButton } from '/@/shared/components/copy-button/copy-button'; import { Group } from '/@/shared/components/group/group'; @@ -17,12 +18,13 @@ export type SongPathProps = { export const SongPath = ({ path }: SongPathProps) => { const { t } = useTranslation(); + const resolvedPath = useResolvedSongPath(path); - if (!path) return null; + if (!resolvedPath) return null; return ( - + {({ copied, copy }) => ( { { - util.openItem(path).catch((error) => { + util.openItem(resolvedPath).catch((error) => { toast.error({ message: (error as Error).message, title: t('error.openError'), @@ -53,7 +55,7 @@ export const SongPath = ({ path }: SongPathProps) => { /> )} - {path} + {resolvedPath} ); }; diff --git a/src/renderer/features/login/routes/login-route.tsx b/src/renderer/features/login/routes/login-route.tsx index a3833df53..468b8a7c6 100644 --- a/src/renderer/features/login/routes/login-route.tsx +++ b/src/renderer/features/login/routes/login-route.tsx @@ -6,6 +6,10 @@ import { Navigate } from 'react-router'; import { api } from '/@/renderer/api'; import { PageHeader } from '/@/renderer/components/page-header/page-header'; +import { + findExistingServerLockServer, + normalizeServerUrl, +} from '/@/renderer/features/action-required/utils/server-lock'; import { isLegacyAuth, isServerLock, @@ -19,6 +23,7 @@ import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-e import { AppRoute } from '/@/renderer/router/routes'; import { getServerById, + useAuthStore, useAuthStoreActions, useCurrentServer, useServerList, @@ -51,12 +56,10 @@ const SERVER_NAMES: Record = { [ServerType.SUBSONIC]: 'OpenSubsonic', }; -const normalizeUrl = (url: string) => url.replace(/\/$/, ''); - const LoginRoute = () => { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); - const { addServer, setCurrentServer, updateServer } = useAuthStoreActions(); + const { addServer, deleteServer, setCurrentServer, updateServer } = useAuthStoreActions(); const currentServer = useCurrentServer(); const serverList = useServerList(); @@ -151,15 +154,16 @@ const LoginRoute = () => { }); } - const normalizedUrl = normalizeUrl(serverUrl); - const normalizedRemoteURL = normalizeUrl(remoteUrl); - const existingServer = - serverLock && - Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl); + const normalizedUrl = normalizeServerUrl(serverUrl); + const normalizedRemoteURL = normalizeServerUrl(remoteUrl); + const existingServer = serverLock + ? findExistingServerLockServer(serverList, normalizedUrl, serverType) + : undefined; + const serverId = existingServer?.id ?? nanoid(); const serverItem: ServerListItemWithCredential = { credential: data.credential, - id: nanoid(), + id: serverId, isAdmin: data.isAdmin, name: serverName, remoteUrl: normalizedRemoteURL, @@ -173,6 +177,9 @@ const LoginRoute = () => { const updates: Partial = { credential: data.credential, isAdmin: data.isAdmin, + name: serverName, + remoteUrl: normalizedRemoteURL, + url: normalizedUrl, userId: data.userId, username: data.username, }; @@ -190,12 +197,20 @@ const LoginRoute = () => { setCurrentServer(serverItem); } + if (serverLock) { + Object.values(useAuthStore.getState().serverList).forEach((server) => { + if (server.id !== serverId) { + deleteServer(server.id); + } + }); + } + toast.success({ message: t('form.addServer.success'), }); if (localSettings && values.password) { - const saved = await localSettings.passwordSet(values.password, serverItem.id); + const saved = await localSettings.passwordSet(values.password, serverId); if (!saved) { toast.error({ message: t('form.addServer.error', { diff --git a/src/renderer/features/lyrics/api/lyric-conversion.ts b/src/renderer/features/lyrics/api/lyric-conversion.ts new file mode 100644 index 000000000..d58697101 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyric-conversion.ts @@ -0,0 +1,315 @@ +import { SyncedWordCue } from '/@/shared/types/domain-types'; + +export type LyricTextToken = { + endChar: number; + startChar: number; + text: string; +}; + +export type RomajiToken = LyricTextToken & { + romaji: string; +}; + +const rangesOverlap = (aStart: number, aEnd: number, bStart: number, bEnd: number): boolean => + aStart < bEnd && bStart < aEnd; + +const sliceRomajiForOverlap = ( + token: RomajiToken, + overlapStart: number, + overlapEnd: number, +): string => { + const tokenLen = token.endChar - token.startChar; + if (tokenLen <= 0 || overlapEnd <= overlapStart || !token.romaji.length) { + return ''; + } + + const relStart = Math.max(0, overlapStart - token.startChar); + const relEnd = Math.min(tokenLen, overlapEnd - token.startChar); + if (relEnd <= relStart) { + return ''; + } + + const startIdx = Math.floor((relStart / tokenLen) * token.romaji.length); + const endIdx = + relEnd >= tokenLen + ? token.romaji.length + : Math.floor((relEnd / tokenLen) * token.romaji.length); + + if (endIdx <= startIdx) { + return token.romaji.slice(startIdx, Math.min(startIdx + 1, token.romaji.length)); + } + + return token.romaji.slice(startIdx, endIdx); +}; + +const findWordCueEndingToken = ( + token: RomajiToken, + wordRanges: { end: number; start: number }[], +): number => { + let bestIndex = -1; + let bestEnd = -1; + + for (let index = 0; index < wordRanges.length; index += 1) { + const range = wordRanges[index]; + const overlaps = + range.start < token.endChar && + range.end > token.startChar && + range.end <= token.endChar; + + if (overlaps && range.end > bestEnd) { + bestEnd = range.end; + bestIndex = index; + } + } + + return bestIndex; +}; + +const isWhitespaceToken = (token: RomajiToken): boolean => /^\s+$/u.test(token.text); + +type FuriganaToken = LyricTextToken & { + furigana: string; +}; + +const KANJI_RE = /[\u4e00-\u9fff]/u; +const RUBY_BLOCK_RE = /^([^<]*)[^<]*<\/rp>([^<]*)<\/rt>[^<]*<\/rp><\/ruby>$/; + +const sliceReadingByProportion = ( + reading: string, + relStart: number, + relEnd: number, + tokenLen: number, +): string => { + if (tokenLen <= 0 || relEnd <= relStart || !reading.length) { + return ''; + } + + const startIdx = Math.floor((relStart / tokenLen) * reading.length); + const endIdx = + relEnd >= tokenLen ? reading.length : Math.floor((relEnd / tokenLen) * reading.length); + + if (endIdx <= startIdx) { + return reading.slice(startIdx, Math.min(startIdx + 1, reading.length)); + } + + return reading.slice(startIdx, endIdx); +}; + +const wrapRuby = (base: string, reading: string): string => + `${base}(${reading})`; + +const buildPerCharFuriganaSegments = (tokenText: string, furiganaHtml: string): string[] => { + const segments: string[] = []; + let htmlCursor = 0; + + while (htmlCursor < furiganaHtml.length && segments.length < tokenText.length) { + const rubyStart = furiganaHtml.indexOf('', htmlCursor); + + if (rubyStart === -1 || rubyStart > htmlCursor) { + const plainEnd = rubyStart === -1 ? furiganaHtml.length : rubyStart; + const plain = furiganaHtml.slice(htmlCursor, plainEnd); + for (const char of plain) { + segments.push(char); + } + htmlCursor = plainEnd; + continue; + } + + const rubyEnd = furiganaHtml.indexOf('', rubyStart); + if (rubyEnd === -1) { + break; + } + + const rubyBlock = furiganaHtml.slice(rubyStart, rubyEnd + 7); + const baseMatch = rubyBlock.match(RUBY_BLOCK_RE); + if (!baseMatch) { + htmlCursor = rubyEnd + 7; + continue; + } + + const [, base, reading] = baseMatch; + if (base.length === 1) { + segments.push(rubyBlock); + } else { + for (let index = 0; index < base.length; index += 1) { + const char = base[index]; + const charReading = sliceReadingByProportion( + reading, + index, + index + 1, + base.length, + ); + segments.push(KANJI_RE.test(char) ? wrapRuby(char, charReading) : char); + } + } + + htmlCursor = rubyEnd + 7; + } + + if (segments.length === tokenText.length) { + return segments; + } + + return [...tokenText].map((char, index) => { + if (!KANJI_RE.test(char)) { + return char; + } + + const singleRuby = furiganaHtml.match(RUBY_BLOCK_RE); + if (singleRuby && singleRuby[1] === tokenText) { + const charReading = sliceReadingByProportion( + singleRuby[2], + index, + index + 1, + tokenText.length, + ); + return wrapRuby(char, charReading); + } + + return char; + }); +}; + +const sliceFuriganaForOverlap = ( + token: FuriganaToken, + overlapStart: number, + overlapEnd: number, +): string => { + const tokenLen = token.endChar - token.startChar; + if (tokenLen <= 0 || overlapEnd <= overlapStart) { + return ''; + } + + const relStart = Math.max(0, overlapStart - token.startChar); + const relEnd = Math.min(tokenLen, overlapEnd - token.startChar); + if (relEnd <= relStart) { + return ''; + } + + if (relStart === 0 && relEnd === tokenLen) { + return token.furigana; + } + + const charSegments = buildPerCharFuriganaSegments(token.text, token.furigana); + return charSegments.slice(relStart, relEnd).join(''); +}; + +export const alignRomajiTokensToWordCues = ( + cueValue: string, + words: SyncedWordCue[], + tokens: RomajiToken[], +): null | SyncedWordCue[] => { + const joined = words.map((word) => word.text).join(''); + if (joined.length !== cueValue.length) { + return null; + } + + let charOffset = 0; + const wordRanges: { end: number; start: number }[] = []; + const aligned: SyncedWordCue[] = []; + + for (const word of words) { + const wordStart = charOffset; + const wordEnd = charOffset + word.text.length; + charOffset = wordEnd; + wordRanges.push({ end: wordEnd, start: wordStart }); + + const overlapping = tokens.filter((token) => + rangesOverlap(wordStart, wordEnd, token.startChar, token.endChar), + ); + + const romajiParts = overlapping + .map((token) => { + const overlapStart = Math.max(wordStart, token.startChar); + const overlapEnd = Math.min(wordEnd, token.endChar); + return sliceRomajiForOverlap(token, overlapStart, overlapEnd); + }) + .filter((part) => part.length > 0); + + const romajiText = romajiParts.join(' '); + + aligned.push({ + ...word, + text: romajiText, + }); + } + + for (let tokenIndex = 0; tokenIndex < tokens.length - 1; tokenIndex += 1) { + const token = tokens[tokenIndex]; + const nextToken = tokens[tokenIndex + 1]; + + if (isWhitespaceToken(nextToken)) { + continue; + } + + const wordIndex = findWordCueEndingToken(token, wordRanges); + if (wordIndex < 0) { + continue; + } + + const currentText = aligned[wordIndex].text; + if (!currentText || currentText.endsWith(' ')) { + continue; + } + + aligned[wordIndex] = { + ...aligned[wordIndex], + text: `${currentText} `, + }; + } + + return aligned; +}; + +export const alignFuriganaToWordCues = async ( + cueValue: string, + words: SyncedWordCue[], + tokens: LyricTextToken[], + convertFuriganaFragment: (text: string) => Promise, +): Promise => { + const joined = words.map((word) => word.text).join(''); + if (joined.length !== cueValue.length) { + return null; + } + + const furiganaTokens: FuriganaToken[] = await Promise.all( + tokens.map(async (token) => ({ + ...token, + furigana: await convertFuriganaFragment(token.text), + })), + ); + + let charOffset = 0; + const aligned: SyncedWordCue[] = []; + + for (const word of words) { + const wordStart = charOffset; + const wordEnd = charOffset + word.text.length; + charOffset = wordEnd; + + const parts: string[] = []; + + for (const token of furiganaTokens) { + if (!rangesOverlap(wordStart, wordEnd, token.startChar, token.endChar)) { + continue; + } + + const overlapStart = Math.max(wordStart, token.startChar); + const overlapEnd = Math.min(wordEnd, token.endChar); + const furiganaPart = sliceFuriganaForOverlap(token, overlapStart, overlapEnd); + + if (!furiganaPart) { + continue; + } + + parts.push(furiganaPart); + } + + aligned.push({ + ...word, + text: parts.join('') || word.text, + }); + } + + return aligned; +}; diff --git a/src/renderer/features/lyrics/api/lyrics-api.ts b/src/renderer/features/lyrics/api/lyrics-api.ts index 0e07f6202..23886013e 100644 --- a/src/renderer/features/lyrics/api/lyrics-api.ts +++ b/src/renderer/features/lyrics/api/lyrics-api.ts @@ -3,6 +3,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { getDefaultStructuredIndex } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { queryClient, QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSettingsStore } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; @@ -17,7 +18,7 @@ import { QueueSong, Song, StructuredLyric, - SynchronizedLyricsArray, + SynchronizedLyrics, } from '/@/shared/types/domain-types'; import { LyricSource } from '/@/shared/types/domain-types'; import { LyricsResponse } from '/@/shared/types/domain-types'; @@ -47,7 +48,7 @@ const alternateTimeExp = /\[(\d*),(\d*)]([^\n]+)(\n|$)/g; const formatLyrics = (lyrics: string) => { const synchronizedLines = lyrics.matchAll(timeExp); - const formattedLyrics: SynchronizedLyricsArray = []; + const formattedLyrics: SynchronizedLyrics = []; for (const line of synchronizedLines) { const [, minute, sec, ms, text] = line; @@ -57,7 +58,7 @@ const formatLyrics = (lyrics: string) => { const timeInMilis = (minutes * 60 + seconds) * 1000 + milis; - formattedLyrics.push([timeInMilis, text]); + formattedLyrics.push({ startMs: timeInMilis, text }); } if (formattedLyrics.length > 0) return formattedLyrics; @@ -69,7 +70,7 @@ const formatLyrics = (lyrics: string) => { .replaceAll(/\(\d+,\d+\)/g, '') .replaceAll(/\s,/g, ',') .replaceAll(/\s\./g, '.'); - formattedLyrics.push([Number(timeInMilis), cleanText]); + formattedLyrics.push({ startMs: Number(timeInMilis), text: cleanText }); } if (formattedLyrics.length > 0) return formattedLyrics; @@ -149,6 +150,7 @@ export async function fetchLocalLyrics(params: { song: QueueSong; }): Promise { const { serverId, signal, song } = params; + const server = getServerById(serverId); if (!server) throw new Error('Server not found'); @@ -276,7 +278,6 @@ export const lyricsQueries = { const prev = queryClient.getQueryData(lyricsKey); const overrideSelection = prev?.overrideSelection ?? null; const suppressRemoteAuto = prev?.suppressRemoteAuto ?? false; - const selectedStructuredIndex = prev?.selectedStructuredIndex ?? 0; const selectedOffsetMs = prev?.selectedOffsetMs ?? 0; const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics; @@ -328,6 +329,12 @@ export const lyricsQueries = { ]); } + const selectedStructuredIndex = + prev?.selectedStructuredIndex ?? + (Array.isArray(local) && local.length > 0 + ? getDefaultStructuredIndex(local) + : 0); + const partial: Pick< LyricsQueryResult, | 'local' diff --git a/src/renderer/features/lyrics/api/lyrics-rtl.ts b/src/renderer/features/lyrics/api/lyrics-rtl.ts new file mode 100644 index 000000000..786abfdb9 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-rtl.ts @@ -0,0 +1,3 @@ +const RTL_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u; + +export const testRtl = (text: string): boolean => RTL_REGEX.test(text); diff --git a/src/renderer/features/lyrics/api/lyrics-scroll.ts b/src/renderer/features/lyrics/api/lyrics-scroll.ts new file mode 100644 index 000000000..ad45f1b90 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-scroll.ts @@ -0,0 +1,88 @@ +const MIN_SCROLL_DURATION_MS = 350; +const MAX_SCROLL_DURATION_MS = 850; +const SCROLL_DURATION_PER_PX_MS = 0.55; +const INSTANT_SCROLL_THRESHOLD_PX = 2; + +const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3; + +export const computeScrollDurationMs = (deltaPx: number): number => + Math.min( + Math.max(Math.abs(deltaPx) * SCROLL_DURATION_PER_PX_MS, MIN_SCROLL_DURATION_MS), + MAX_SCROLL_DURATION_MS, + ); + +const activeAnimations = new WeakMap(); + +export const cancelScrollAnimation = (element: HTMLElement): void => { + const frameId = activeAnimations.get(element); + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + activeAnimations.delete(element); + } +}; + +export interface AnimateScrollTopOptions { + onComplete?: () => void; + smooth?: boolean; +} + +export const animateScrollTop = ( + element: HTMLElement, + targetTop: number, + options: AnimateScrollTopOptions = {}, +): (() => void) => { + const { onComplete, smooth = true } = options; + const startTop = element.scrollTop; + const delta = targetTop - startTop; + + cancelScrollAnimation(element); + + if (!smooth || Math.abs(delta) <= INSTANT_SCROLL_THRESHOLD_PX) { + element.scrollTop = targetTop; + onComplete?.(); + return () => {}; + } + + const durationMs = computeScrollDurationMs(delta); + const startTime = performance.now(); + let frameId: null | number = null; + let cancelled = false; + + const cancel = (): void => { + if (cancelled) { + return; + } + + cancelled = true; + if (frameId !== null) { + cancelAnimationFrame(frameId); + activeAnimations.delete(element); + frameId = null; + } + }; + + const step = (now: number): void => { + if (cancelled) { + return; + } + + const elapsed = now - startTime; + const progress = Math.min(elapsed / durationMs, 1); + const eased = easeOutCubic(progress); + element.scrollTop = startTop + delta * eased; + + if (progress < 1) { + frameId = requestAnimationFrame(step); + activeAnimations.set(element, frameId); + } else { + activeAnimations.delete(element); + frameId = null; + onComplete?.(); + } + }; + + frameId = requestAnimationFrame(step); + activeAnimations.set(element, frameId); + + return cancel; +}; diff --git a/src/renderer/features/lyrics/api/lyrics-utils.ts b/src/renderer/features/lyrics/api/lyrics-utils.ts new file mode 100644 index 000000000..210a7df37 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-utils.ts @@ -0,0 +1,221 @@ +import { + LyricsKind, + StructuredLyric, + SyncedCueLine, + SyncedWordCue, + SynchronizedLyricLine, + SynchronizedLyrics, + SynchronizedLyricsLineTuple, +} from '/@/shared/types/domain-types'; + +export const isLyricsLineTuple = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): line is SynchronizedLyricsLineTuple => Array.isArray(line); + +export const normalizeLyricsLine = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): SynchronizedLyricLine => { + if (isLyricsLineTuple(line)) { + return { startMs: line[0], text: line[1] ?? '' }; + } + + return { + ...line, + text: line.text ?? '', + }; +}; + +export const normalizeLyrics = ( + lyrics: SynchronizedLyrics | SynchronizedLyricsLineTuple[], +): SynchronizedLyrics => lyrics.map(normalizeLyricsLine); + +export const getLyricLineStartMs = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): number => normalizeLyricsLine(line).startMs; + +export const getLyricLineText = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): string => normalizeLyricsLine(line).text ?? ''; + +export const getCurrentLyricIndex = (lyrics: SynchronizedLyrics, timeInMs: number): number => { + if (!lyrics.length) { + return -1; + } + + let index = -1; + for (let idx = 0; idx < lyrics.length; idx += 1) { + if (timeInMs < getLyricLineStartMs(lyrics[idx])) { + break; + } + index = idx; + } + + return index; +}; + +export const getCurrentWordIndex = (cueLine: SyncedCueLine, timeInMs: number): number => { + if (!cueLine.words.length) { + return -1; + } + + for (let wordIndex = 0; wordIndex < cueLine.words.length; wordIndex += 1) { + const word = cueLine.words[wordIndex]; + if (timeInMs >= word.startMs && timeInMs < word.endMs) { + return wordIndex; + } + } + + return -1; +}; + +export const getWordProgress = (word: SyncedWordCue, timeInMs: number): number => { + const duration = word.endMs - word.startMs; + + if (duration <= 0) { + return timeInMs >= word.endMs ? 100 : 0; + } + + const elapsed = Math.max(0, Math.min(duration, timeInMs - word.startMs)); + return (elapsed / duration) * 100; +}; + +export const getLineEndMs = (line: SynchronizedLyricLine): number => { + if (!line.cueLines?.length) { + return line.startMs; + } + + let endMs = line.startMs; + + for (const cueLine of line.cueLines) { + endMs = Math.max(endMs, cueLine.endMs); + + for (const word of cueLine.words) { + endMs = Math.max(endMs, word.endMs); + } + } + + return endMs; +}; + +export const lyricsHasWordCues = (lyrics: SynchronizedLyrics): boolean => + lyrics.some((line) => line.cueLines?.some((cueLine) => cueLine.words.length > 0)); + +export const findOverlayLineMatchByTime = ( + overlayLyrics: null | SynchronizedLyrics | undefined, + startMs: number, + lineIndex?: number, +): SynchronizedLyricLine | undefined => { + if (!overlayLyrics?.length) { + return undefined; + } + + if (lineIndex !== undefined && lineIndex >= 0 && lineIndex < overlayLyrics.length) { + const indexedLine = normalizeLyricsLine(overlayLyrics[lineIndex]); + if (indexedLine.startMs <= startMs) { + return indexedLine; + } + } + + let match: SynchronizedLyricLine | undefined; + + for (const rawLine of overlayLyrics) { + const line = normalizeLyricsLine(rawLine); + if (line.startMs <= startMs) { + match = line; + continue; + } + + break; + } + + return match; +}; + +export const findOverlayLineByTime = ( + overlayLyrics: null | SynchronizedLyrics | undefined, + startMs: number, + lineIndex?: number, +): string | undefined => findOverlayLineMatchByTime(overlayLyrics, startMs, lineIndex)?.text; + +export const overlayLineHasWordCues = (line: SynchronizedLyricLine | undefined): boolean => + !!line?.cueLines?.some((cueLine) => cueLine.words.length > 0); + +export const getOverlayCueLinesForLine = ( + overlayLyrics: null | SynchronizedLyrics | undefined, + mainLineStartMs: number, + lineIndex?: number, +): null | SyncedCueLine[] => { + const match = findOverlayLineMatchByTime(overlayLyrics, mainLineStartMs, lineIndex); + + if (!match?.cueLines?.length || !overlayLineHasWordCues(match)) { + return null; + } + + return match.cueLines; +}; + +export type LyricsLayers = { + main: StructuredLyric[]; + others: StructuredLyric[]; + overlayLayers: StructuredLyric[]; + pronunciation: null | StructuredLyric; + translation: null | StructuredLyric; +}; + +const getStructuredKind = (lyric: StructuredLyric): LyricsKind => { + if (!lyric.synced) { + return 'main'; + } + + return lyric.kind ?? 'main'; +}; + +export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => { + const main: StructuredLyric[] = []; + const others: StructuredLyric[] = []; + const overlayLayers: StructuredLyric[] = []; + let pronunciation: null | StructuredLyric = null; + let translation: null | StructuredLyric = null; + + for (const lyric of local) { + const kind = getStructuredKind(lyric); + + if (kind === 'main') { + main.push(lyric); + } else { + others.push(lyric); + + if (lyric.synced) { + overlayLayers.push(lyric); + } + } + + if (kind === 'translation' && !translation) { + translation = lyric; + } + + if (kind === 'pronunciation' && !pronunciation) { + pronunciation = lyric; + } + } + + return { main, others, overlayLayers, pronunciation, translation }; +}; + +export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => { + const mainIndex = local.findIndex( + (lyric) => lyric.synced && getStructuredKind(lyric) === 'main', + ); + + return mainIndex >= 0 ? mainIndex : 0; +}; + +export const getOverlayLayerKey = (lyric: StructuredLyric): string => { + const kind = getStructuredKind(lyric); + return `${kind}:${lyric.lang}`; +}; + +export const formatStructuredLyricLabel = (lyric: StructuredLyric): string => { + const kind = getStructuredKind(lyric); + return kind === 'main' ? lyric.lang : `${lyric.lang} (${kind})`; +}; diff --git a/src/renderer/features/lyrics/api/split-word-cue.ts b/src/renderer/features/lyrics/api/split-word-cue.ts new file mode 100644 index 000000000..92ffa8b37 --- /dev/null +++ b/src/renderer/features/lyrics/api/split-word-cue.ts @@ -0,0 +1,64 @@ +import { SyncedWordCue } from '/@/shared/types/domain-types'; + +// eslint-disable-next-line no-irregular-whitespace +const BREAK_CHAR_RE = /[\s​­\p{Dash_Punctuation}]/u; +const TRAILING_WS_RE = /\s+$/; +const SEGMENT_LENGTH_THRESHOLD = 5; +const GRAPHEME_WRAP_RE = + /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}\p{sc=Thai}\p{sc=Lao}\p{sc=Khmer}\p{sc=Myanmar}]/u; + +export type SplitWordCue = SyncedWordCue & { + isWrapAfter?: boolean; +}; + +const shouldSplit = (text: string): boolean => { + const core = text.replace(TRAILING_WS_RE, ''); + if (core.length <= SEGMENT_LENGTH_THRESHOLD) { + return false; + } + + return !BREAK_CHAR_RE.test(core); +}; + +const segment = (text: string): string[] => { + try { + const wordSeg = new Intl.Segmenter(undefined, { granularity: 'word' }); + const words = Array.from(wordSeg.segment(text), (entry) => entry.segment); + if (words.length > 1) { + return words; + } + + if (!GRAPHEME_WRAP_RE.test(text)) { + return [text]; + } + + const graphSeg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return Array.from(graphSeg.segment(text), (entry) => entry.segment); + } catch { + return Array.from(text); + } +}; + +export const splitWordCue = (word: SyncedWordCue): SplitWordCue[] => { + if (!shouldSplit(word.text)) { + return [{ ...word, isWrapAfter: false }]; + } + + const segments = segment(word.text); + if (segments.length <= 1) { + return [{ ...word, isWrapAfter: false }]; + } + + const duration = word.endMs - word.startMs; + const perDuration = duration / segments.length; + + return segments.map((seg, index) => ({ + endMs: word.startMs + (index + 1) * perDuration, + isWrapAfter: index < segments.length - 1, + startMs: word.startMs + index * perDuration, + text: seg, + })); +}; + +export const splitWordCues = (words: SyncedWordCue[]): SplitWordCue[] => + words.flatMap((word) => splitWordCue(word)); diff --git a/src/renderer/features/lyrics/components/lyrics-export-form.tsx b/src/renderer/features/lyrics/components/lyrics-export-form.tsx index 97224a206..1ba705360 100644 --- a/src/renderer/features/lyrics/components/lyrics-export-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-export-form.tsx @@ -35,7 +35,7 @@ export const LyricsExportForm = ({ lyrics, offsetMs, synced }: LyricsExportFormP const contents = lyrics.lyrics .map( (lyric) => - `[${formatDuration(lyric[0], { leading: true, ms: true })}]${lyric[1]}`, + `[${formatDuration(lyric.startMs, { leading: true, ms: true })}]${lyric.text}`, ) .join('\n'); @@ -46,7 +46,7 @@ ${contents} `; } else { if (Array.isArray(lyrics.lyrics)) { - return lyrics.lyrics.map((lyric) => lyric[1]).join('\n') + '\n'; + return lyrics.lyrics.map((lyric) => lyric.text).join('\n') + '\n'; } return lyrics.lyrics; } diff --git a/src/renderer/features/lyrics/components/lyrics-scroll-content.module.css b/src/renderer/features/lyrics/components/lyrics-scroll-content.module.css new file mode 100644 index 000000000..18fc85fdc --- /dev/null +++ b/src/renderer/features/lyrics/components/lyrics-scroll-content.module.css @@ -0,0 +1,13 @@ +.content { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + + @media screen and (orientation: portrait) { + padding-top: 5vh !important; + padding-right: var(--lyric-padding-right, 0%) !important; + padding-bottom: 5vh !important; + padding-left: var(--lyric-padding-left, 0%) !important; + } +} diff --git a/src/renderer/features/lyrics/components/lyrics-scroll-content.tsx b/src/renderer/features/lyrics/components/lyrics-scroll-content.tsx new file mode 100644 index 000000000..e6a014536 --- /dev/null +++ b/src/renderer/features/lyrics/components/lyrics-scroll-content.tsx @@ -0,0 +1,45 @@ +import clsx from 'clsx'; +import { useMemo } from 'react'; + +import styles from './lyrics-scroll-content.module.css'; + +export interface LyricsScrollContentProps { + bottomScrollPadding?: string; + children: React.ReactNode; + className?: string; + gap?: number; + paddingLeft?: number; + paddingRight?: number; + style?: React.CSSProperties; +} + +export const LyricsScrollContent = ({ + bottomScrollPadding = '50vh', + children, + className, + gap, + paddingLeft = 0, + paddingRight = 0, + style, +}: LyricsScrollContentProps) => { + const contentStyle = useMemo( + () => + ({ + '--lyric-padding-left': `${paddingLeft}%`, + '--lyric-padding-right': `${paddingRight}%`, + gap: gap !== undefined ? `${gap}px` : undefined, + paddingBottom: bottomScrollPadding, + paddingLeft: `${paddingLeft}%`, + paddingRight: `${paddingRight}%`, + paddingTop: '10vh', + ...style, + }) as React.CSSProperties, + [bottomScrollPadding, gap, paddingLeft, paddingRight, style], + ); + + return ( +
+ {children} +
+ ); +}; diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index 036ed7e57..230e3bffa 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -9,7 +9,9 @@ import styles from './lyrics-search-form.module.css'; import i18n from '/@/i18n/i18n'; import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api'; +import { lyricsHasWordCues } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; +import { SynchronizedKaraokeLyrics } from '/@/renderer/features/lyrics/synchronized-karaoke-lyrics'; import { SynchronizedLyrics, SynchronizedLyricsProps, @@ -258,16 +260,29 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch style={{ width: '100%' }} > {Array.isArray(previewData) ? ( - + lyricsHasWordCues(previewData) ? ( + + ) : ( + + ) ) : ( `${t('page.fullscreenPlayer.config.lyricGap')} (${t('page.fullscreenPlayer.config.unsynchronized')})`, ), }, + { + control: ( + `${value}%`} + max={50} + min={0} + onChangeEnd={(value) => { + updateDisplaySetting({ paddingLeft: value }); + }} + step={1} + w={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricPaddingLeft'), + }, + { + control: ( + `${value}%`} + max={50} + min={0} + onChangeEnd={(value) => { + updateDisplaySetting({ paddingRight: value }); + }} + step={1} + w={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricPaddingRight'), + }, { control: ( description: '', title: t('page.fullscreenPlayer.config.followCurrentLyric'), }, + { + control: ( + value.toString()} + max={50} + min={-50} + onChangeEnd={(value) => { + updateLyricsSetting({ followScrollAlignment: value }); + }} + step={1} + w={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment'), + }, + { + control: ( + { + const value = Number(e.currentTarget.value); + updateLyricsSetting({ lineLeadTimeMs: value }); + }} + step={50} + width={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricLineLeadTime'), + }, { control: ( isHidden: !isElectron(), title: t('setting.lyricFetchProvider'), }, + { + control: ( + + updateLyricsSetting({ enableFurigana: e.currentTarget.checked }) + } + /> + ), + description: t('setting.enableFurigana', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableFurigana'), + }, + { + control: ( + updateLyricsSetting({ enableRomaji: e.currentTarget.checked })} + /> + ), + description: t('setting.enableRomaji', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableRomaji'), + }, { control: ( void) | null; + doneFirstInstantScroll: boolean; + nextScrollAllowedTime: number; + pendingScroll: boolean; + programmaticScrollUntil: number; + queuedScroll: boolean; + scrollPos: number; + scrollResumeTime: number; + skipScrolls: number; + skipScrollsDecayTimes: number[]; + wasUserScrolling: boolean; +} + +export interface TickOptions { + currentTimeMs: number; + eventCreationTime: number; + follow?: boolean; + followScrollAlignment?: number; + forceResync?: boolean; + isPlaying: boolean; + lineLeadTimeMs?: number; + lyricsData: LyricsData; + onLineActive?: (lineIndex: number) => void; + scrollContainer: HTMLElement; + smoothScroll?: boolean; +} + +export const getFollowScrollPositionRatio = (followScrollAlignment = 0): number => { + return Math.min(0.95, Math.max(0.05, CENTER_SCROLL_POS_RATIO + followScrollAlignment / 100)); +}; + +const reflow = (element: HTMLElement): void => { + void element.offsetHeight; +}; + +export const createScrollState = (): ScrollState => ({ + cancelScroll: null, + doneFirstInstantScroll: false, + nextScrollAllowedTime: 0, + pendingScroll: false, + programmaticScrollUntil: 0, + queuedScroll: false, + scrollPos: -1, + scrollResumeTime: 0, + skipScrolls: 0, + skipScrollsDecayTimes: [], + wasUserScrolling: false, +}); + +export const createAnimEngineState = (): AnimEngineState => ({ + lastActiveElements: [], + lastEventCreationTime: 0, + lastPlayState: false, + lastTime: 0, + scroll: createScrollState(), + selectedElementIndex: 0, +}); + +export const resetAnimEngine = (state: AnimEngineState): void => { + cancelActiveLyricsScroll(state.scroll); + state.lastActiveElements = []; + state.scroll.skipScrollsDecayTimes = []; + state.scroll.doneFirstInstantScroll = false; + state.scroll.pendingScroll = false; + state.scroll.queuedScroll = false; +}; + +const collectLineParts = (lineElement: HTMLElement): PartData[] => { + const words = lineElement.querySelectorAll( + '.karaoke-word[data-duration]:not(.karaoke-overlay-word)', + ); + + return Array.from(words).map((element) => ({ + animationStartTimeMs: Number.POSITIVE_INFINITY, + duration: Number.parseFloat(element.dataset.duration ?? '0'), + element, + isAnimating: false, + time: Number.parseFloat(element.dataset.time ?? '0'), + })); +}; + +const collectOverlayParts = (lineElement: HTMLElement): PartData[] => { + const words = lineElement.querySelectorAll('.karaoke-overlay-word[data-duration]'); + + return Array.from(words).map((element) => ({ + animationStartTimeMs: Number.POSITIVE_INFINITY, + duration: Number.parseFloat(element.dataset.duration ?? '0'), + element, + isAnimating: false, + time: Number.parseFloat(element.dataset.time ?? '0'), + })); +}; + +const cancelSungAnimationCleanup = (element: HTMLElement): void => { + const existingTimeout = Number(element.dataset.sungCleanupTimeout); + if (existingTimeout) { + window.clearTimeout(existingTimeout); + delete element.dataset.sungCleanupTimeout; + } +}; + +const completeSungVisualCleanup = (element: HTMLElement): void => { + cancelSungAnimationCleanup(element); + element.style.removeProperty('--karaoke-swipe-delay'); + element.style.removeProperty('--karaoke-anim-delay'); + element.classList.remove(ANIMATING_CLASS); + element.classList.remove(PRE_ANIMATING_CLASS); + element.classList.remove(PAUSED_CLASS); +}; + +const getRemainingHighlightDurationSec = ( + durationSec: number, + startTimeSec: number, + interpolatedTimeSec: number, +): number => { + const highlightDurationSec = durationSec * HIGHLIGHT_DURATION_MULTIPLIER; + const elapsedSec = Math.max(0, interpolatedTimeSec - startTimeSec); + return Math.max(0, highlightDurationSec - elapsedSec); +}; + +const scheduleSungAnimationCleanup = ( + element: HTMLElement, + durationSec: number, + startTimeSec: number, + interpolatedTimeSec: number, +): void => { + const remainingSec = getRemainingHighlightDurationSec( + durationSec, + startTimeSec, + interpolatedTimeSec, + ); + + if (remainingSec <= 0.05) { + completeSungVisualCleanup(element); + return; + } + + cancelSungAnimationCleanup(element); + + const timeoutId = window.setTimeout(() => { + delete element.dataset.sungCleanupTimeout; + completeSungVisualCleanup(element); + }, remainingSec * 1000); + + element.dataset.sungCleanupTimeout = String(timeoutId); +}; + +const clearElementAnimation = (element: HTMLElement): void => { + cancelSungAnimationCleanup(element); + element.style.removeProperty('--karaoke-swipe-delay'); + element.style.removeProperty('--karaoke-anim-delay'); + element.classList.remove(ANIMATING_CLASS); + element.classList.remove(PRE_ANIMATING_CLASS); + element.classList.remove(PAUSED_CLASS); +}; + +const clearWordSungState = (part: PartData): void => { + part.element.classList.remove('sung'); +}; + +const markWordSung = (part: PartData, interpolatedTimeSec: number): void => { + part.animationStartTimeMs = Number.POSITIVE_INFINITY; + part.isAnimating = false; + part.element.classList.add('sung'); + scheduleSungAnimationCleanup(part.element, part.duration, part.time, interpolatedTimeSec); +}; + +const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: number): void => { + const partTimeDelta = interpolatedTimeSec - part.time; + + part.element.classList.remove(ANIMATING_CLASS); + part.element.classList.remove(PAUSED_CLASS); + part.element.style.setProperty( + '--karaoke-swipe-delay', + `${-partTimeDelta - part.duration * 0.1}s`, + ); + part.element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`); + part.element.classList.add(PRE_ANIMATING_CLASS); + + reflow(part.element); + + part.element.classList.add(ANIMATING_CLASS); + part.animationStartTimeMs = now - partTimeDelta * 1000; + part.isAnimating = true; +}; + +const animateWordParts = (parts: PartData[], interpolatedTimeSec: number, now: number): void => { + for (const part of parts) { + const partEnd = part.time + part.duration; + + if (interpolatedTimeSec >= partEnd) { + if (!part.element.classList.contains('sung')) { + markWordSung(part, interpolatedTimeSec); + } + } else if (interpolatedTimeSec < part.time) { + if (part.isAnimating || part.element.classList.contains('sung')) { + clearPartAnimation(part); + clearWordSungState(part); + } + } else if (!part.isAnimating) { + setupPartAnimation(part, interpolatedTimeSec, now); + } + } +}; + +const clearWordParts = (parts: PartData[], interpolatedTimeSec: number): void => { + for (const part of parts) { + if (interpolatedTimeSec >= part.time + part.duration) { + markWordSung(part, interpolatedTimeSec); + } else { + clearPartAnimation(part); + clearWordSungState(part); + } + } +}; + +const resumeWordParts = (parts: PartData[]): void => { + for (const part of parts) { + part.element.classList.remove(PAUSED_CLASS); + } +}; + +const clearLineOverlayState = (lineElement: HTMLElement): void => { + for (const element of lineElement.querySelectorAll('.karaoke-overlay-word')) { + clearElementAnimation(element); + element.classList.remove('sung'); + } +}; + +export const buildLyricsDataFromDom = ({ + container, + lineIdPrefix, + lyrics, +}: BuildLyricsDataOptions): LyricsData | null => { + const hasWordCues = lyricsHasWordCues(lyrics); + const lines: LineData[] = []; + + for (let index = 0; index < lyrics.length; index += 1) { + const lineElement = document.getElementById(`${lineIdPrefix}-${index}`); + if (!lineElement) { + continue; + } + + const line = lyrics[index]; + const startMs = getLyricLineStartMs(line); + const endMs = getLineEndMs(line); + const durationMs = Math.max(0, endMs - startMs); + const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : []; + const overlayParts = collectOverlayParts(lineElement); + + lines.push({ + accumulatedOffsetMs: 0, + animationStartTimeMs: Number.POSITIVE_INFINITY, + duration: durationMs / 1000, + element: lineElement, + hasWordCues: parts.length > 0, + height: -1, + isAnimating: false, + isAnimationPlayStatePlaying: false, + isScrolled: false, + isSelected: false, + lastAnimSetupAt: 0, + overlayParts, + parts, + position: -1, + time: startMs / 1000, + }); + } + + if (!lines.length) { + return null; + } + + const allZero = lines.every((line) => line.time === 0); + let syncType: LyricsSyncType = allZero ? 'none' : 'synced'; + if (hasWordCues) { + syncType = 'richsync'; + } + + return { + container, + lineIdPrefix, + lines, + syncType, + }; +}; + +export const recalculateLinePositions = (lyricsData: LyricsData): void => { + const { container, lines } = lyricsData; + const containerRect = container.getBoundingClientRect(); + + for (const line of lines) { + const rect = line.element.getBoundingClientRect(); + line.position = rect.top - containerRect.top + container.scrollTop; + line.height = rect.height; + } +}; + +const clearPartAnimation = (part: PartData): void => { + cancelSungAnimationCleanup(part.element); + part.element.style.removeProperty('--karaoke-swipe-delay'); + part.element.style.removeProperty('--karaoke-anim-delay'); + part.element.classList.remove(ANIMATING_CLASS); + part.element.classList.remove(PRE_ANIMATING_CLASS); + part.element.classList.remove(PAUSED_CLASS); + part.animationStartTimeMs = Number.POSITIVE_INFINITY; + part.isAnimating = false; +}; + +const clearLineAnimation = (line: LineData): void => { + line.element.style.removeProperty('--karaoke-swipe-delay'); + line.element.style.removeProperty('--karaoke-anim-delay'); + line.element.classList.remove(LINE_ANIMATING_CLASS); + line.element.classList.remove(LINE_PRE_ANIMATING_CLASS); + line.element.classList.remove(PAUSED_CLASS); + line.animationStartTimeMs = Number.POSITIVE_INFINITY; +}; + +const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => { + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + clearPartAnimation(part); + clearWordSungState(part); + } + } else { + clearLineAnimation(lineData); + } + + if (lineData.overlayParts.length) { + clearWordParts(lineData.overlayParts, interpolatedTimeSec); + } + + lineData.isSelected = false; + lineData.isAnimating = false; + lineData.isAnimationPlayStatePlaying = false; +}; + +const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => { + const timeDelta = interpolatedTimeSec - line.time; + + line.element.classList.remove(LINE_ANIMATING_CLASS); + line.element.classList.remove(PAUSED_CLASS); + line.element.style.setProperty('--karaoke-swipe-delay', `${-timeDelta - line.duration * 0.1}s`); + line.element.style.setProperty('--karaoke-anim-delay', `${-timeDelta}s`); + line.element.classList.add(LINE_PRE_ANIMATING_CLASS); + + reflow(line.element); + + line.element.classList.add(LINE_ANIMATING_CLASS); + line.animationStartTimeMs = now - timeDelta * 1000; + line.isAnimating = true; + line.lastAnimSetupAt = now; + line.isAnimationPlayStatePlaying = true; + line.accumulatedOffsetMs = 0; +}; + +const decaySkipScrolls = (state: ScrollState, now: number): void => { + let decayCount = 0; + + for (const decayTime of state.skipScrollsDecayTimes) { + if (decayTime > now) { + break; + } + decayCount += 1; + } + + if (decayCount > 0) { + state.skipScrollsDecayTimes = state.skipScrollsDecayTimes.slice(decayCount); + state.skipScrolls = Math.max(0, state.skipScrolls - decayCount); + } +}; + +const cancelActiveLyricsScroll = (ss: ScrollState): void => { + if (ss.cancelScroll) { + ss.cancelScroll(); + ss.cancelScroll = null; + } +}; + +export const animateLyricsScrollTo = ( + state: AnimEngineState, + scrollContainer: HTMLElement, + scrollPos: number, + smoothScroll = true, +): number => { + const ss = state.scroll; + const now = Date.now(); + const scrollTop = scrollContainer.scrollTop; + + cancelActiveLyricsScroll(ss); + + let useSmoothScroll = smoothScroll; + if (scrollTop === 0 && !ss.doneFirstInstantScroll) { + useSmoothScroll = false; + ss.doneFirstInstantScroll = true; + ss.nextScrollAllowedTime = 0; + } + + const delta = scrollPos - scrollTop; + + if (useSmoothScroll && Math.abs(delta) > 2) { + const durationMs = computeScrollDurationMs(delta); + ss.cancelScroll = animateScrollTop(scrollContainer, scrollPos); + ss.nextScrollAllowedTime = durationMs + now + 20; + ss.programmaticScrollUntil = now + durationMs + 50; + ss.scrollPos = scrollPos; + ss.skipScrolls += 1; + ss.skipScrollsDecayTimes.push(now + 2000); + return durationMs; + } + + scrollContainer.scrollTop = scrollPos; + ss.programmaticScrollUntil = now + 100; + ss.scrollPos = scrollPos; + ss.skipScrolls += 1; + ss.skipScrollsDecayTimes.push(now + 2000); + return 0; +}; + +const scrollToPosition = ( + state: AnimEngineState, + scrollContainer: HTMLElement, + scrollPos: number, + smoothScroll: boolean, +): void => { + animateLyricsScrollTo(state, scrollContainer, scrollPos, smoothScroll); +}; + +export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): number => { + const now = Date.now(); + const { + currentTimeMs, + follow = true, + forceResync = false, + isPlaying, + smoothScroll = true, + } = opts; + const { eventCreationTime, lyricsData, onLineActive, scrollContainer } = opts; + + if (currentTimeMs === 0 && !isPlaying) { + return -1; + } + + const currentTimeSec = currentTimeMs / 1000; + const timeJumped = + forceResync || + Math.abs( + currentTimeSec - + state.lastTime - + (eventCreationTime - state.lastEventCreationTime) / 1000, + ) > TIME_JUMP_THRESHOLD; + + if (timeJumped) { + cancelActiveLyricsScroll(state.scroll); + + for (const lineData of lyricsData.lines) { + lineData.isAnimating = false; + lineData.isSelected = false; + lineData.isAnimationPlayStatePlaying = false; + + if (lineData.isScrolled) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + lineData.isScrolled = false; + } + + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + clearPartAnimation(part); + clearWordSungState(part); + } + } else { + clearLineAnimation(lineData); + } + + if (lineData.overlayParts.length) { + for (const part of lineData.overlayParts) { + clearPartAnimation(part); + clearWordSungState(part); + } + } + } + + state.scroll.pendingScroll = true; + } + + state.lastTime = currentTimeSec; + state.lastPlayState = isPlaying; + state.lastEventCreationTime = eventCreationTime; + + const timeOffsetSec = isPlaying ? (now - eventCreationTime) / 1000 : 0; + const playbackTimeSec = currentTimeSec + timeOffsetSec; + + const timingOffsetMs = + lyricsData.syncType === 'richsync' ? RICHSYNC_TIMING_OFFSET_MS : SYNC_TIMING_OFFSET_MS; + const interpolatedTimeSec = playbackTimeSec + timingOffsetMs / 1000; + const leadTimeSec = (opts.lineLeadTimeMs ?? DEFAULT_LINE_LEAD_TIME_MS) / 1000; + + const { lineIdPrefix, lines, syncType } = lyricsData; + const isLineSyncMode = lineIdPrefix === 'lyric'; + if (syncType === 'none') { + return -1; + } + + const scrollHeight = scrollContainer.getBoundingClientRect().height; + const activeElems: LineData[] = []; + let newLyricSelected = timeJumped; + let activeLineIndex = -1; + + if (!isPlaying) { + for (const lineData of lines) { + clearLineKaraokeHighlights(lineData, interpolatedTimeSec); + + if (lineData.isScrolled) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + lineData.isScrolled = false; + } + } + } + + for (let index = 0; index < lines.length; index += 1) { + const lineData = lines[index]; + const time = lineData.time; + const nextTime = + index + 1 < lines.length ? lines[index + 1].time : Number.POSITIVE_INFINITY; + const isInLineWindow = + playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration; + + const isScrollCandidate = playbackTimeSec >= time - leadTimeSec && isInLineWindow; + + const isVisuallyActive = isLineSyncMode + ? playbackTimeSec >= time && isInLineWindow + : isScrollCandidate; + + if (isScrollCandidate) { + activeElems.push(lineData); + state.selectedElementIndex = index; + activeLineIndex = index; + + if (isPlaying && !lineData.isScrolled) { + newLyricSelected = true; + state.scroll.pendingScroll = true; + lineData.isScrolled = true; + onLineActive?.(index); + + if (!isLineSyncMode) { + lineData.element.classList.add(LINE_ACTIVE_CLASS); + } + } + } else if (lineData.isScrolled) { + lineData.isScrolled = false; + + if (!isLineSyncMode) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + } + } + + if (isLineSyncMode) { + if (isVisuallyActive) { + lineData.element.classList.add(LINE_ACTIVE_CLASS); + } else { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + } + } + + if (!isPlaying) { + continue; + } + + if (isLineSyncMode && !lineData.hasWordCues) { + continue; + } + + const setUpEarly = 2; + const effectiveEnd = Math.max(nextTime, time + lineData.duration + 0.05); + const isLineInRange = + interpolatedTimeSec + setUpEarly >= time && interpolatedTimeSec < effectiveEnd; + + if (isLineInRange) { + lineData.isSelected = true; + + if (lineData.hasWordCues) { + resumeWordParts(lineData.parts); + } else { + lineData.element.classList.remove(PAUSED_CLASS); + } + + if (lineData.overlayParts.length) { + resumeWordParts(lineData.overlayParts); + } + + lineData.isAnimationPlayStatePlaying = true; + + if (!lineData.hasWordCues && !lineData.isAnimating) { + setupLineAnimation(lineData, interpolatedTimeSec, now); + } + + if (lineData.hasWordCues) { + animateWordParts(lineData.parts, interpolatedTimeSec, now); + } + + if (lineData.overlayParts.length) { + animateWordParts(lineData.overlayParts, interpolatedTimeSec, now); + } + } else if (lineData.isSelected) { + if (lineData.hasWordCues) { + clearWordParts(lineData.parts, interpolatedTimeSec); + } else { + clearLineAnimation(lineData); + } + + if (lineData.overlayParts.length) { + clearWordParts(lineData.overlayParts, interpolatedTimeSec); + } + + lineData.isSelected = false; + lineData.isAnimating = false; + lineData.isAnimationPlayStatePlaying = false; + } + } + + const ss = state.scroll; + const scrollPausedByUser = ss.scrollResumeTime >= now; + const canAutoscroll = follow && !scrollPausedByUser; + + if (canAutoscroll) { + if (activeElems.length === 0 && lines.length > 0) { + activeElems.push(lines[0]); + } + + state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time); + + if (activeElems.length > 0) { + const scrollPosRatio = getFollowScrollPositionRatio(opts.followScrollAlignment); + const scrollPosOffset = scrollHeight * scrollPosRatio; + const lastActive = activeElems[activeElems.length - 1]; + const useLastActiveOnly = newLyricSelected || activeElems.length > 1; + const scrollLines = useLastActiveOnly ? [lastActive] : activeElems; + const positions = scrollLines + .filter( + (lineData, index) => + playbackTimeSec < + lineData.time + lineData.duration - DEFAULT_ENDING_THRESHOLD || + index === scrollLines.length - 1, + ) + .map((lineData) => lineData.position + lineData.height / 2); + + if (positions.length > 0 && positions.every((pos) => pos >= 0)) { + const avgPos = positions.reduce((sum, pos) => sum + pos, 0) / positions.length; + let scrollPos = avgPos - scrollPosOffset; + scrollPos = Math.min(scrollPos, scrollLines[0].position); + scrollPos = Math.max( + scrollPos, + lastActive.position - scrollHeight + lastActive.height, + ); + scrollPos = Math.min(scrollPos, lastActive.position); + scrollPos = Math.max(0, scrollPos); + + const shouldScroll = + ss.wasUserScrolling || newLyricSelected || ss.queuedScroll || ss.pendingScroll; + const canScrollNow = now > ss.nextScrollAllowedTime; + + if (shouldScroll) { + if (canScrollNow) { + ss.queuedScroll = false; + ss.pendingScroll = false; + scrollToPosition(state, scrollContainer, scrollPos, smoothScroll); + } else { + ss.queuedScroll = true; + } + } + } + } + } + + decaySkipScrolls(ss, now); + + if (ss.wasUserScrolling && ss.scrollResumeTime < now) { + ss.wasUserScrolling = false; + ss.pendingScroll = true; + } + + return activeLineIndex; +}; + +export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => { + const ss = state.scroll; + cancelActiveLyricsScroll(ss); + ss.programmaticScrollUntil = 0; + ss.skipScrolls = 0; + ss.skipScrollsDecayTimes = []; + ss.wasUserScrolling = true; + ss.scrollResumeTime = Date.now() + pauseDurationMs; +}; + +export const resumeLyricsAutoscroll = (state: AnimEngineState): void => { + state.scroll.wasUserScrolling = false; + state.scroll.scrollResumeTime = 0; +}; + +export const shouldSkipLyricsScrollEvent = (state: AnimEngineState): boolean => { + if (Date.now() < state.scroll.programmaticScrollUntil) { + return true; + } + + if (state.scroll.skipScrolls > 0) { + state.scroll.skipScrolls -= 1; + return true; + } + + return false; +}; + +export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void => { + if (!lyricsData) { + return; + } + + for (const line of lyricsData.lines) { + line.element.classList.remove( + LINE_ACTIVE_CLASS, + LINE_ANIMATING_CLASS, + LINE_PRE_ANIMATING_CLASS, + 'singing', + 'complete', + ); + line.isScrolled = false; + line.isSelected = false; + line.isAnimating = false; + + for (const part of line.parts) { + clearPartAnimation(part); + clearWordSungState(part); + } + + for (const part of line.overlayParts) { + clearPartAnimation(part); + clearWordSungState(part); + } + + clearLineOverlayState(line.element); + clearLineAnimation(line); + } +}; diff --git a/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts new file mode 100644 index 000000000..cfe6ebf7b --- /dev/null +++ b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts @@ -0,0 +1,188 @@ +import { useQuery } from '@tanstack/react-query'; +import isElectron from 'is-electron'; + +import { + alignFuriganaToWordCues, + alignRomajiTokensToWordCues, + LyricTextToken, + RomajiToken, +} from '/@/renderer/features/lyrics/api/lyric-conversion'; +import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types'; + +const lyricsApi = isElectron() ? window.api.lyrics : null; + +const convertSyncedLyricsFurigana = async ( + lyrics: SynchronizedLyrics, +): Promise => { + if (!lyricsApi) { + return lyrics; + } + + return Promise.all( + lyrics.map(async (line) => ({ + ...line, + cueLines: line.cueLines + ? await Promise.all( + line.cueLines.map(async (cueLine) => { + const tokens = (await lyricsApi.parseLyricsTextTokens( + cueLine.value, + )) as LyricTextToken[]; + const alignedWords = cueLine.words.length + ? await alignFuriganaToWordCues( + cueLine.value, + cueLine.words, + tokens, + (text) => lyricsApi.convertFuriganaFragment(text), + ) + : cueLine.words; + return { + ...cueLine, + value: await lyricsApi.convertFurigana(cueLine.value), + words: alignedWords ?? cueLine.words, + }; + }), + ) + : undefined, + text: await lyricsApi.convertFurigana(line.text), + })), + ); +}; + +const convertSyncedLyricsRomaji = async ( + lyrics: SynchronizedLyrics, + convert: (text: string) => Promise, +): Promise => + Promise.all( + lyrics.map(async (line) => ({ + ...line, + cueLines: line.cueLines + ? await Promise.all( + line.cueLines.map(async (cueLine) => ({ + ...cueLine, + value: await convert(cueLine.value), + words: await Promise.all( + cueLine.words.map(async (word) => ({ + ...word, + text: await convert(word.text), + })), + ), + })), + ) + : undefined, + text: await convert(line.text), + })), + ); + +export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async () => { + if (!lyrics || !lyricsApi || !enabled) return lyrics; + + if (typeof lyrics === 'string') { + return await lyricsApi.convertFurigana(lyrics); + } + + if (Array.isArray(lyrics)) { + return convertSyncedLyricsFurigana(lyrics); + } + + return lyrics; + }, + queryKey: ['furigana', lyrics], + staleTime: Infinity, + }); +}; + +export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async () => { + if (!lyrics || !lyricsApi || !enabled) return lyrics; + + if (typeof lyrics === 'string') { + return await lyricsApi.convertRomaji(lyrics); + } + + if (Array.isArray(lyrics)) { + return convertSyncedLyricsRomaji(lyrics, (text) => lyricsApi.convertRomaji(text)); + } + + return lyrics; + }, + queryKey: ['romaji', lyrics], + staleTime: Infinity, + }); +}; + +export type SyncedRomajiLyrics = ((null | SyncedCueLine)[] | null)[]; + +const buildSyncedRomajiLine = async ( + cueLines: SyncedCueLine[], +): Promise<(null | SyncedCueLine)[]> => { + const romajiCueLines: (null | SyncedCueLine)[] = []; + + for (const cueLine of cueLines) { + if (!cueLine.words.length) { + romajiCueLines.push(null); + continue; + } + + if (!lyricsApi) { + return cueLines.map(() => null); + } + + const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[]; + if (!tokens.length) { + romajiCueLines.push(null); + continue; + } + + const alignedWords = alignRomajiTokensToWordCues(cueLine.value, cueLine.words, tokens); + + if (!alignedWords) { + romajiCueLines.push(null); + continue; + } + + romajiCueLines.push({ + ...cueLine, + words: alignedWords, + }); + } + + return romajiCueLines; +}; + +export const useSyncedRomajiLyrics = ( + lyrics: null | SynchronizedLyrics | undefined, + enabled: boolean, +) => { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async (): Promise => { + if (!lyrics || !lyricsApi || !enabled) { + return null; + } + + const result: SyncedRomajiLyrics = []; + + for (const line of lyrics) { + if ( + !line.cueLines?.length || + !line.cueLines.some((cueLine) => cueLine.words.length) + ) { + result.push(null); + continue; + } + + const romajiCueLines = await buildSyncedRomajiLine(line.cueLines); + result.push(romajiCueLines.some((entry) => entry !== null) ? romajiCueLines : null); + } + + return result; + }, + queryKey: ['romaji-synced', lyrics], + staleTime: Infinity, + }); +}; diff --git a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts new file mode 100644 index 000000000..2ef116d3c --- /dev/null +++ b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { + AnimEngineState, + buildLyricsDataFromDom, + BuildLyricsDataOptions, + createAnimEngineState, + LyricsData, + recalculateLinePositions, + resetAnimEngine, + resetLyricsAnimationDom, + resumeLyricsAutoscroll, + tickLyricsAnimation, +} from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine'; +import { SynchronizedLyrics } from '/@/shared/types/domain-types'; + +export interface UseLyricsAnimationEngineOptions { + animStateRef?: React.MutableRefObject; + containerRef: React.RefObject; + enabled?: boolean; + followRef?: React.RefObject; + followScrollAlignmentRef?: React.RefObject; + fontSize?: number; + gap?: number; + lineIdPrefix: 'karaoke-line' | 'lyric'; + lineLeadTimeMsRef?: React.RefObject; + lyrics: SynchronizedLyrics; + onLineActive?: (lineIndex: number) => void; + paddingLeft?: number; + paddingRight?: number; + scrollContainerId: string; +} + +export const useLyricsAnimationEngine = ({ + animStateRef: externalAnimStateRef, + containerRef, + enabled = true, + followRef, + followScrollAlignmentRef, + fontSize, + gap, + lineIdPrefix, + lineLeadTimeMsRef, + lyrics, + onLineActive, + paddingLeft, + paddingRight, + scrollContainerId, +}: UseLyricsAnimationEngineOptions) => { + const internalAnimStateRef = useRef(createAnimEngineState()); + const animStateRef = externalAnimStateRef ?? internalAnimStateRef; + const lyricsDataRef = useRef(null); + const onLineActiveRef = useRef(onLineActive); + + useEffect(() => { + onLineActiveRef.current = onLineActive; + }, [onLineActive]); + + const rebuildLyricsData = useCallback(() => { + const container = containerRef.current; + if (!container || !enabled) { + lyricsDataRef.current = null; + return null; + } + + const options: BuildLyricsDataOptions = { + container, + lineIdPrefix, + lyrics, + }; + + const data = buildLyricsDataFromDom(options); + lyricsDataRef.current = data; + + if (data) { + recalculateLinePositions(data); + animStateRef.current.scroll.wasUserScrolling = true; + } + + return data; + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, [containerRef, enabled, lineIdPrefix, lyrics]); + + const reset = useCallback(() => { + resetAnimEngine(animStateRef.current); + resetLyricsAnimationDom(lyricsDataRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + const recalculatePositions = useCallback(() => { + if (lyricsDataRef.current) { + recalculateLinePositions(lyricsDataRef.current); + animStateRef.current.scroll.pendingScroll = true; + animStateRef.current.scroll.wasUserScrolling = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + const tick = useCallback( + ( + currentTimeMs: number, + isPlaying: boolean, + options?: { eventCreationTime?: number; forceResync?: boolean }, + ): number => { + if (!enabled) { + return -1; + } + + let lyricsData = lyricsDataRef.current; + if (!lyricsData) { + lyricsData = rebuildLyricsData(); + } + + if (!lyricsData) { + return -1; + } + + const scrollContainer = + document.getElementById(scrollContainerId) ?? containerRef.current ?? undefined; + + if (!scrollContainer) { + return -1; + } + + return tickLyricsAnimation(animStateRef.current, { + currentTimeMs, + eventCreationTime: options?.eventCreationTime ?? Date.now(), + follow: followRef?.current ?? true, + followScrollAlignment: followScrollAlignmentRef?.current ?? 0, + forceResync: options?.forceResync ?? false, + isPlaying, + lineLeadTimeMs: lineLeadTimeMsRef?.current, + lyricsData, + onLineActive: onLineActiveRef.current, + scrollContainer, + smoothScroll: true, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef, containerRef, followRef, lineLeadTimeMsRef are read via .current at call time + [enabled, rebuildLyricsData, scrollContainerId], + ); + + const resumeAutoscroll = useCallback(() => { + resumeLyricsAutoscroll(animStateRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + useEffect(() => { + reset(); + const frame = requestAnimationFrame(() => { + rebuildLyricsData(); + }); + + return () => { + cancelAnimationFrame(frame); + reset(); + }; + }, [lyrics, enabled, rebuildLyricsData, reset]); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + recalculatePositions(); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [fontSize, gap, paddingLeft, paddingRight, recalculatePositions]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const observedElements = new Set(); + + const observer = new ResizeObserver(() => { + recalculatePositions(); + }); + + const observeLayoutTargets = () => { + if (!observedElements.has(container)) { + observer.observe(container); + observedElements.add(container); + } + + const content = container.firstElementChild; + if (content && !observedElements.has(content)) { + observer.observe(content); + observedElements.add(content); + } + }; + + observeLayoutTargets(); + + const mutationObserver = new MutationObserver(() => { + observeLayoutTargets(); + }); + mutationObserver.observe(container, { childList: true }); + + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + }; + }, [containerRef, recalculatePositions]); + + return { + animStateRef, + lyricsDataRef, + rebuildLyricsData, + recalculatePositions, + reset, + resumeAutoscroll, + tick, + }; +}; diff --git a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts new file mode 100644 index 000000000..359745eaf --- /dev/null +++ b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts @@ -0,0 +1,275 @@ +import isElectron from 'is-electron'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; + +import { + animateLyricsScrollTo, + createAnimEngineState, + handleLyricsUserScroll, + resumeLyricsAutoscroll, + shouldSkipLyricsScrollEvent, +} from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine'; +import { + useLyricsDisplaySettings, + useLyricsSettings, + usePlaybackType, + usePlayerActions, +} from '/@/renderer/store'; +import { SynchronizedLyrics } from '/@/shared/types/domain-types'; +import { PlayerType } from '/@/shared/types/types'; + +const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; +const utils = isElectron() ? window.api.utils : null; +const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; + +export const LYRICS_SCROLL_CONTAINER_ID = 'sychronized-lyrics-scroll-container'; +export const MANUAL_SCROLL_PAUSE_MS = 2000; +const MANUAL_SCROLL_DRIFT_PX = 3; + +export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: number) => { + const playbackType = usePlaybackType(); + const lyricsSettings = useLyricsSettings(); + const displaySettings = useLyricsDisplaySettings(settingsKey); + const { mediaSeekToTimestamp } = usePlayerActions(); + + const settings = useMemo( + () => ({ + ...lyricsSettings, + fontSize: + displaySettings.fontSize && displaySettings.fontSize !== 0 + ? displaySettings.fontSize + : 24, + gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24, + opacityNonActive: displaySettings.opacityNonActive, + paddingLeft: displaySettings.paddingLeft ?? 0, + paddingRight: displaySettings.paddingRight ?? 0, + scaleNonActive: + displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0 + ? displaySettings.scaleNonActive + : 0.95, + }), + [displaySettings, lyricsSettings], + ); + + const effectiveOffsetMs = offsetMs ?? 0; + const delayMsRef = useRef(effectiveOffsetMs); + const followRef = useRef(settings.follow); + const followScrollAlignmentRef = useRef(settings.followScrollAlignment); + const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs); + const userScrollingRef = useRef(false); + const scrollTimeoutRef = useRef>(null); + const containerRef = useRef(null); + const programmaticScrollRef = useRef(false); + const programmaticScrollTimeoutRef = useRef>(null); + const lyricRef = useRef(null); + const scrollAnimStateRef = useRef(createAnimEngineState()); + + const handleSeek = useCallback( + (time: number) => { + if (playbackType === PlayerType.LOCAL && mpvPlayer) { + mpvPlayer.seekTo(time); + } else { + mpris?.updateSeek(time); + mediaSeekToTimestamp(time); + } + }, + [mediaSeekToTimestamp, playbackType], + ); + + const handleLineClick = useCallback( + (event: React.MouseEvent) => { + const target = (event.target as HTMLElement).closest('[data-lyric-time]'); + if (!target) { + return; + } + + const time = Number((target as HTMLElement).dataset.lyricTime); + if (time >= 0 && Number.isFinite(time)) { + handleSeek(time / 1000); + } + }, + [handleSeek], + ); + + const scrollToLine = useCallback( + (_index: number, lineSelector: string, anchorSelector?: string) => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + const lineElement = document.querySelector(lineSelector) as HTMLElement | null; + const anchorElement = anchorSelector + ? (document.querySelector(anchorSelector) as HTMLElement | null) + : null; + const scrollTarget = anchorElement ?? lineElement; + + if (!followRef.current || userScrollingRef.current || !scrollTarget || !doc) { + return; + } + + const containerRect = doc.getBoundingClientRect(); + const targetRect = scrollTarget.getBoundingClientRect(); + const targetCenterY = targetRect.top + targetRect.height / 2; + const containerCenterY = containerRect.top + containerRect.height / 2; + const scrollTop = doc.scrollTop + targetCenterY - containerCenterY; + + programmaticScrollRef.current = true; + const durationMs = animateLyricsScrollTo(scrollAnimStateRef.current, doc, scrollTop); + + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + + programmaticScrollTimeoutRef.current = setTimeout( + () => { + programmaticScrollRef.current = false; + }, + durationMs > 0 ? durationMs + 50 : 150, + ); + }, + [], + ); + + const resumeAutoscroll = useCallback(() => { + userScrollingRef.current = false; + resumeLyricsAutoscroll(scrollAnimStateRef.current); + }, []); + + const pauseManualScrollFollow = useCallback(() => { + userScrollingRef.current = true; + handleLyricsUserScroll(scrollAnimStateRef.current, MANUAL_SCROLL_PAUSE_MS); + + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + userScrollingRef.current = false; + }, MANUAL_SCROLL_PAUSE_MS); + }, []); + + const containerStyle = useMemo( + () => + ({ + '--lyric-opacity': settings.opacityNonActive, + '--lyric-scale': settings.scaleNonActive, + '--lyric-scale-origin': settings.alignment, + }) as React.CSSProperties, + [settings.alignment, settings.opacityNonActive, settings.scaleNonActive], + ); + + const hideScrollbar = useCallback(() => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + doc?.classList.add('hide-scrollbar'); + }, []); + + const showScrollbar = useCallback(() => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + doc?.classList.remove('hide-scrollbar'); + }, []); + + useEffect(() => { + followRef.current = settings.follow; + }, [settings.follow]); + + useEffect(() => { + followScrollAlignmentRef.current = settings.followScrollAlignment; + }, [settings.followScrollAlignment]); + + useEffect(() => { + lineLeadTimeMsRef.current = settings.lineLeadTimeMs; + }, [settings.lineLeadTimeMs]); + + useEffect(() => { + const newOffset = offsetMs ?? 0; + if (delayMsRef.current === newOffset) { + return; + } + + delayMsRef.current = newOffset; + }, [offsetMs]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const handleWheel = (event: WheelEvent) => { + if (event.deltaX === 0 && event.deltaY === 0) { + return; + } + + pauseManualScrollFollow(); + }; + + const handleTouchStart = () => { + pauseManualScrollFollow(); + }; + + const handleScroll = () => { + const scrollState = scrollAnimStateRef.current.scroll; + const isProgrammatic = Date.now() < scrollState.programmaticScrollUntil; + + if ( + !isProgrammatic && + scrollState.scrollPos >= 0 && + Math.abs(container.scrollTop - scrollState.scrollPos) > MANUAL_SCROLL_DRIFT_PX + ) { + pauseManualScrollFollow(); + return; + } + + if (shouldSkipLyricsScrollEvent(scrollAnimStateRef.current)) { + return; + } + + if (programmaticScrollRef.current) { + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + + programmaticScrollTimeoutRef.current = setTimeout(() => { + programmaticScrollRef.current = false; + }, 150); + + return; + } + + pauseManualScrollFollow(); + }; + + container.addEventListener('wheel', handleWheel, { passive: true }); + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('scroll', handleScroll); + + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + }; + }, [pauseManualScrollFollow]); + + return { + containerRef, + containerStyle, + delayMsRef, + followRef, + followScrollAlignmentRef, + handleLineClick, + handleSeek, + hideScrollbar, + lineLeadTimeMsRef, + lyricRef, + resumeAutoscroll, + scrollAnimStateRef, + scrollToLine, + settings, + showScrollbar, + userScrollingRef, + }; +}; diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.module.css b/src/renderer/features/lyrics/karaoke-lyric-line.module.css new file mode 100644 index 000000000..8392154dc --- /dev/null +++ b/src/renderer/features/lyrics/karaoke-lyric-line.module.css @@ -0,0 +1,68 @@ +.karaoke-line { + width: 100%; + max-width: 100%; + padding: 0 1rem; + font-weight: 600; + line-height: 1.2; + color: var(--theme-colors-foreground); + word-break: normal; + overflow-wrap: break-word; + opacity: var(--lyric-opacity); + transform: scale(var(--lyric-scale)); + transform-origin: var(--lyric-scale-origin) center; + transition: + opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); + will-change: opacity, transform; +} + +.karaoke-line:global(.lyrics-line-active), +.karaoke-line:global(.lyrics-line-animating), +.karaoke-line:global(.lyrics-line-pre-animating) { + opacity: 1 !important; + transform: scale(1); +} + +.karaoke-line:global(.lyrics-line-animating) { + transition-delay: var(--karaoke-anim-delay, 0s); +} + +.karaoke-line:global(.synchronized) { + cursor: pointer; +} + +.karaoke-word { + display: inline; +} + +.overlay-line { + display: block; + max-width: 100%; + font-size: 0.8em; + font-weight: 600; + overflow-wrap: break-word; + white-space: normal; +} + +.translation-line { + display: block; + max-width: 100%; + font-size: 0.85em; + font-weight: 500; + overflow-wrap: break-word; + white-space: normal; +} + +.agent-line { + width: 100%; + max-width: 100%; + text-align: inherit; +} + +.agent-text { + display: block; + width: 100%; + max-width: 100%; + overflow-wrap: break-word; + white-space: normal; +} diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.tsx b/src/renderer/features/lyrics/karaoke-lyric-line.tsx new file mode 100644 index 000000000..3ca5ed98d --- /dev/null +++ b/src/renderer/features/lyrics/karaoke-lyric-line.tsx @@ -0,0 +1,323 @@ +import clsx from 'clsx'; +import { ComponentPropsWithoutRef, memo, useMemo } from 'react'; + +import styles from './karaoke-lyric-line.module.css'; + +import { testRtl } from '/@/renderer/features/lyrics/api/lyrics-rtl'; +import { splitWordCues } from '/@/renderer/features/lyrics/api/split-word-cue'; +import { sanitize } from '/@/renderer/utils/sanitize'; +import { Box } from '/@/shared/components/box/box'; +import { Stack } from '/@/shared/components/stack/stack'; +import { LyricAgent, SyncedCueLine } from '/@/shared/types/domain-types'; + +const LONG_WORD_THRESHOLD_MS = 1500; +const FURIGANA_HTML_RE = /]/i; + +const hasFuriganaHtml = (text: string): boolean => FURIGANA_HTML_RE.test(text); + +interface KaraokeLyricLineProps extends ComponentPropsWithoutRef<'div'> { + agents?: LyricAgent[]; + alignment: 'center' | 'left' | 'right'; + cueLines: SyncedCueLine[]; + extraOverlays?: Array<{ + cueLines: null | SyncedCueLine[]; + text?: null | string; + }>; + fontSize: number; + lineIndex: number; + pronunciationCueLines?: null | SyncedCueLine[]; + pronunciationText?: null | string; + romajiCueLines?: (null | SyncedCueLine)[] | null; + romajiText?: null | string; + text?: string; + translatedText?: null | string; + translationCueLines?: null | SyncedCueLine[]; +} + +type WordSpanVariant = 'main' | 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation'; + +const getWordSpanClasses = ( + variant: WordSpanVariant, + options: { + hasFurigana: boolean; + isBackground: boolean; + isRtl: boolean; + isZeroDuration: boolean; + }, +) => { + const isOverlay = variant !== 'main'; + + return clsx( + styles.karaokeWord, + 'karaoke-word', + isOverlay && 'karaoke-overlay-word', + variant === 'overlay-generic' && 'karaoke-overlay-generic', + variant === 'overlay-pronunciation' && 'karaoke-overlay-pronunciation', + variant === 'overlay-translation' && 'karaoke-overlay-translation', + options.isRtl && 'karaoke-rtl', + options.isBackground && 'karaoke-bg-vocal', + options.isZeroDuration && 'karaoke-zero-dur', + options.hasFurigana && 'karaoke-furigana-word', + ); +}; + +const getWordSpanIdPrefix = (variant: WordSpanVariant): string => { + switch (variant) { + case 'overlay-generic': + return 'karaoke-overlay-generic'; + case 'overlay-pronunciation': + return 'karaoke-overlay-pronunciation'; + case 'overlay-translation': + return 'karaoke-overlay-translation'; + default: + return 'karaoke'; + } +}; + +const renderWordSpans = ( + cueLine: SyncedCueLine, + lineIndex: number, + cueLineIndex: number, + isBackground: boolean, + variant: WordSpanVariant = 'main', +) => { + const isOverlay = variant !== 'main'; + const idPrefix = getWordSpanIdPrefix(variant); + + if (!cueLine.words.length) { + return ( + + ); + } + + const splitWords = + isOverlay || cueLine.words.some((word) => hasFuriganaHtml(word.text)) + ? cueLine.words + : splitWordCues(cueLine.words); + let wordCounter = 0; + + return splitWords.map((word) => { + const durationMs = word.endMs - word.startMs; + const durationSec = durationMs / 1000; + const timeSec = word.startMs / 1000; + const isRtl = testRtl(word.text); + const isZeroDuration = durationMs <= 0; + const hasFurigana = !isOverlay && hasFuriganaHtml(word.text); + const sanitizedHtml = sanitize(word.text); + const currentWordIndex = wordCounter; + wordCounter += 1; + + const wordClassName = getWordSpanClasses(variant, { + hasFurigana, + isBackground, + isRtl, + isZeroDuration, + }); + + const wordKey = `${word.startMs}-${currentWordIndex}`; + const wordProps = { + className: wordClassName, + 'data-duration': String(durationSec), + 'data-lyric-time': cueLine.startMs, + 'data-time': String(timeSec), + 'data-word-start': word.startMs, + id: `${idPrefix}-${lineIndex}-cue-${cueLineIndex}-word-${currentWordIndex}`, + style: { + '--karaoke-duration': `${durationMs}ms`, + } as React.CSSProperties, + ...(durationMs > LONG_WORD_THRESHOLD_MS ? { 'data-long-word': true as const } : {}), + }; + + return ( + + + + + ); + }); +}; + +const renderOverlayCueLineRow = ( + cueLine: SyncedCueLine, + lineIndex: number, + cueLineIndex: number, + variant: 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation', + lineClassName: string, +) => ( + + {renderWordSpans(cueLine, lineIndex, cueLineIndex, false, variant)} + +); + +const hasSyncedOverlayCueLines = (cueLines: null | SyncedCueLine[] | undefined): boolean => + !!cueLines?.some((cueLine) => cueLine.words.length > 0); + +export const KaraokeLyricLine = memo( + ({ + agents, + alignment, + className, + cueLines, + extraOverlays, + fontSize, + lineIndex, + pronunciationCueLines, + pronunciationText, + romajiCueLines, + romajiText, + translatedText, + translationCueLines, + ...props + }: KaraokeLyricLineProps) => { + const style = useMemo( + () => ({ + fontSize, + textAlign: alignment, + }), + [fontSize, alignment], + ); + + const hasSyncedRomajiOverlay = romajiCueLines !== undefined; + const hasSyncedPronunciation = hasSyncedOverlayCueLines(pronunciationCueLines); + const hasSyncedTranslation = hasSyncedOverlayCueLines(translationCueLines); + const pronunciationFallbackText = + !hasSyncedRomajiOverlay && !hasSyncedPronunciation + ? (pronunciationText ?? romajiText) + : null; + + return ( + + + {cueLines.map((cueLine, cueLineIndex) => { + const agent = agents?.find((entry) => entry.id === cueLine.agentId); + const isBackground = agent?.role === 'bg' || agent?.role === 'group'; + const romajiCueLine = romajiCueLines?.[cueLineIndex]; + const pronunciationCueLine = pronunciationCueLines?.[cueLineIndex]; + const translationCueLine = translationCueLines?.[cueLineIndex]; + + return ( +
+ + {renderWordSpans( + cueLine, + lineIndex, + cueLineIndex, + isBackground, + 'main', + )} + + {romajiCueLine && ( + + {renderWordSpans( + romajiCueLine, + lineIndex, + cueLineIndex, + false, + 'overlay-pronunciation', + )} + + )} + {!romajiCueLine && + pronunciationCueLine && + renderOverlayCueLineRow( + pronunciationCueLine, + lineIndex, + cueLineIndex, + 'overlay-pronunciation', + styles.overlayLine, + )} + {translationCueLine && + renderOverlayCueLineRow( + translationCueLine, + lineIndex, + cueLineIndex, + 'overlay-translation', + styles.translationLine, + )} +
+ ); + })} + {pronunciationFallbackText && ( + + )} + {!hasSyncedTranslation && translatedText && ( + + )} + {extraOverlays?.map((overlay, overlayIndex) => { + const hasSyncedExtra = hasSyncedOverlayCueLines(overlay.cueLines); + + if (hasSyncedExtra && overlay.cueLines) { + return overlay.cueLines.map((cueLine, cueLineIndex) => ( + + {renderWordSpans( + cueLine, + lineIndex, + cueLineIndex, + false, + 'overlay-generic', + )} + + )); + } + + if (overlay.text) { + return ( + + ); + } + + return null; + })} +
+
+ ); + }, +); + +KaraokeLyricLine.displayName = 'KaraokeLyricLine'; diff --git a/src/renderer/features/lyrics/lyric-line.module.css b/src/renderer/features/lyrics/lyric-line.module.css index a06f3cc87..3cf21c641 100644 --- a/src/renderer/features/lyrics/lyric-line.module.css +++ b/src/renderer/features/lyrics/lyric-line.module.css @@ -10,14 +10,21 @@ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform; + will-change: opacity, transform; } +.lyric-line:global(.lyrics-line-active), +.lyric-line:global(.lyrics-line-animating), +.lyric-line:global(.lyrics-line-pre-animating), .lyric-line:global(.active) { opacity: 1 !important; transform: scale(1); } +.lyric-line:global(.lyrics-line-animating) { + transition-delay: var(--karaoke-anim-delay, 0s); +} + .lyric-line:global(.unsynchronized) { opacity: 1; } @@ -25,3 +32,8 @@ .lyric-line:global(.synchronized) { cursor: pointer; } + +.romaji-line { + font-size: 0.8em; + font-weight: 600; +} diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index 75ccd11d6..270b1f031 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -3,18 +3,29 @@ import { ComponentPropsWithoutRef, memo, useMemo } from 'react'; import styles from './lyric-line.module.css'; +import { sanitize } from '/@/renderer/utils/sanitize'; import { Box } from '/@/shared/components/box/box'; import { Stack } from '/@/shared/components/stack/stack'; interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { alignment: 'center' | 'left' | 'right'; fontSize: number; - text: string; + romajiText?: null | string; + text?: string; + translatedText?: null | string; } export const LyricLine = memo( - ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { - const lines = useMemo(() => text.split('_BREAK_'), [text]); + ({ + alignment, + className, + fontSize, + romajiText, + text, + translatedText, + ...props + }: LyricLineProps) => { + const lines = useMemo(() => (text ?? '').split('_BREAK_'), [text]); const style = useMemo( () => ({ @@ -28,8 +39,17 @@ export const LyricLine = memo( {lines.map((line, index) => ( - {line} + ))} + {romajiText && ( + + )} + {translatedText && ( + + )} ); diff --git a/src/renderer/features/lyrics/lyrics-actions.module.css b/src/renderer/features/lyrics/lyrics-actions.module.css new file mode 100644 index 000000000..e18013f73 --- /dev/null +++ b/src/renderer/features/lyrics/lyrics-actions.module.css @@ -0,0 +1,39 @@ +.root { + position: relative; + width: 100%; +} + +.top-row, +.controls-row { + flex-wrap: wrap; +} + +.top-row { + display: flex; + align-items: center; + justify-content: center; + padding-bottom: var(--theme-spacing-md); +} + +.controls-row { + display: flex; + align-items: center; + justify-content: center; +} + +.overlay-toggle-active { + color: var(--theme-colors-primary) !important; +} + +.layer-row { + display: flex; + gap: var(--theme-spacing-md); + align-items: center; + justify-content: space-between; +} + +.layer-label { + flex: 1; + min-width: 0; + text-align: left; +} diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 9d488b3d9..a39f5068a 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -1,16 +1,29 @@ import isElectron from 'is-electron'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import styles from './lyrics-actions.module.css'; + import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form'; import { useLyricsSettings, usePlayerSong } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; -import { Center } from '/@/shared/components/center/center'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Group } from '/@/shared/components/group/group'; +import { AppIcon } from '/@/shared/components/icon/icon'; import { NumberInput } from '/@/shared/components/number-input/number-input'; -import { Select } from '/@/shared/components/select/select'; +import { Popover } from '/@/shared/components/popover/popover'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { Text } from '/@/shared/components/text/text'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; -import { LyricsOverride } from '/@/shared/types/domain-types'; +import { LyricsKind, LyricsOverride } from '/@/shared/types/domain-types'; + +export type OverlayLayerToggle = { + key: string; + kind: LyricsKind; + label: string; +}; interface LyricsActionsProps { hasLyrics: boolean; @@ -20,13 +33,39 @@ interface LyricsActionsProps { onExportLyrics: () => void; onRemoveLyric: () => void; onSearchOverride: (params: LyricsOverride) => void; + onToggleOverlayLayer?: (key: string) => void; onTranslateLyric?: () => void; onUpdateOffset: (offsetMs: number) => void; + overlayLayers?: OverlayLayerToggle[]; setIndex: (idx: number) => void; settingsKey?: string; synced?: boolean; + visibleOverlayKeys?: Set; } +const OVERLAY_KIND_ICONS: Partial> = { + pronunciation: 'audioLines', + translation: 'languages', +}; + +const getOverlayTooltip = ( + layer: OverlayLayerToggle, + t: (key: string) => string, + isActive: boolean, +): string => { + const action = isActive ? 'Hide' : 'Show'; + + if (layer.kind === 'pronunciation') { + return `${action} ${t('page.fullscreenPlayer.showPronunciation').toLowerCase()}`; + } + + if (layer.kind === 'translation') { + return `${action} ${t('page.fullscreenPlayer.showTranslation').toLowerCase()}`; + } + + return `${action} ${layer.label}`; +}; + export const LyricsActions = ({ hasLyrics, index, @@ -35,9 +74,12 @@ export const LyricsActions = ({ onExportLyrics, onRemoveLyric, onSearchOverride, + onToggleOverlayLayer, onTranslateLyric, onUpdateOffset, + overlayLayers = [], setIndex, + visibleOverlayKeys = new Set(), }: LyricsActionsProps) => { const { t } = useTranslation(); const currentSong = usePlayerSong(); @@ -49,21 +91,149 @@ export const LyricsActions = ({ const isActionsDisabled = !currentSong; const isDesktop = isElectron(); + const hasServerTranslationLayer = overlayLayers.some((layer) => layer.kind === 'translation'); + const hasMultipleLanguages = languages.length > 1; + + const selectedLanguage = useMemo( + () => languages.find((language) => language.value === index.toString()), + [index, languages], + ); + + const { extraOverlayLayers, quickOverlayLayers } = useMemo(() => { + const quick: OverlayLayerToggle[] = []; + const extra: OverlayLayerToggle[] = []; + + for (const layer of overlayLayers) { + if (layer.kind === 'pronunciation' || layer.kind === 'translation') { + quick.push(layer); + continue; + } + + extra.push(layer); + } + + return { + extraOverlayLayers: extra, + quickOverlayLayers: quick, + }; + }, [overlayLayers]); + + const hasActiveExtraOverlay = extraOverlayLayers.some((layer) => + visibleOverlayKeys.has(layer.key), + ); + + const languageTooltip = selectedLanguage + ? `${t('page.fullscreenPlayer.lyricLanguage')}: ${selectedLanguage.label}` + : t('page.fullscreenPlayer.lyricLanguage'); + + const showTopRow = + hasLyrics || + hasMultipleLanguages || + quickOverlayLayers.length > 0 || + extraOverlayLayers.length > 0; + + const languageMenu = hasMultipleLanguages ? ( + + + + + + {languages.map((language) => ( + setIndex(parseInt(language.value, 10))} + > + {language.label} + + ))} + + + ) : null; + + const overlayToggleIcons = quickOverlayLayers.map((layer) => { + const isActive = visibleOverlayKeys.has(layer.key); + const icon = OVERLAY_KIND_ICONS[layer.kind] ?? 'list'; + + return onToggleOverlayLayer ? ( + onToggleOverlayLayer(layer.key)} + size="sm" + tooltip={{ + label: getOverlayTooltip(layer, t, isActive), + openDelay: 0, + }} + variant="subtle" + /> + ) : null; + }); + + const extraLayersPopover = + extraOverlayLayers.length > 0 && onToggleOverlayLayer ? ( + + + + + e.stopPropagation()} p="sm"> + + + {t('page.fullscreenPlayer.lyricLayers')} + + {extraOverlayLayers.map((layer) => ( +
+ + {layer.label} + + onToggleOverlayLayer(layer.key)} + /> +
+ ))} +
+
+
+ ) : null; return ( - <> -
- {hasLyrics && ( -
- {languages.length > 1 && ( - { @@ -115,10 +127,8 @@ export const AudioSettings = memo(() => { }} /> ), - description: t('setting.audioPlayer', { - context: 'description', - }), - isHidden: !isElectron(), + description: t('setting.audioPlayer', { context: 'description' }), + isHidden: !isElectron() && !isJukeboxSupported, note: status === PlayerStatus.PLAYING ? t('common.playerMustBePaused') : undefined, title: t('setting.audioPlayer'), }, @@ -139,9 +149,7 @@ export const AudioSettings = memo(() => { } /> ), - description: t('setting.audioDevice', { - context: 'description', - }), + description: t('setting.audioDevice', { context: 'description' }), isHidden: !isElectron(), title: t('setting.audioDevice'), }, @@ -156,9 +164,7 @@ export const AudioSettings = memo(() => { }} /> ), - description: t('setting.webAudio', { - context: 'description', - }), + description: t('setting.webAudio', { context: 'description' }), isHidden: settings.type !== PlayerType.WEB, note: t('common.restartRequired'), title: t('setting.webAudio'), @@ -174,9 +180,7 @@ export const AudioSettings = memo(() => { }} /> ), - description: t('setting.preservePitch', { - context: 'description', - }), + description: t('setting.preservePitch', { context: 'description' }), isHidden: settings.type !== PlayerType.WEB, title: t('setting.preservePitch'), }, @@ -186,16 +190,12 @@ export const AudioSettings = memo(() => { defaultChecked={settings.audioFadeOnStatusChange} onChange={(e) => { setSettings({ - playback: { - audioFadeOnStatusChange: e.currentTarget.checked, - }, + playback: { audioFadeOnStatusChange: e.currentTarget.checked }, }); }} /> ), - description: t('setting.audioFadeOnStatusChange', { - context: 'description', - }), + description: t('setting.audioFadeOnStatusChange', { context: 'description' }), title: t('setting.audioFadeOnStatusChange'), }, ]; diff --git a/src/renderer/features/settings/components/playback/eq-settings.tsx b/src/renderer/features/settings/components/playback/eq-settings.tsx new file mode 100644 index 000000000..c27ecb281 --- /dev/null +++ b/src/renderer/features/settings/components/playback/eq-settings.tsx @@ -0,0 +1,852 @@ +import { useMove } from '@mantine/hooks'; +import isElectron from 'is-electron'; +import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + buildMpvAudioFilters, + type CompressorSettings, + type EqSettings as EqSettingsType, +} from './mpv-audio-filters'; + +import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; +import { + SettingOption, + SettingsSection, +} from '/@/renderer/features/settings/components/settings-section'; +import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Group } from '/@/shared/components/group/group'; +import { NumberInput } from '/@/shared/components/number-input/number-input'; +import { Select } from '/@/shared/components/select/select'; +import { Slider } from '/@/shared/components/slider/slider'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Switch } from '/@/shared/components/switch/switch'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { Text } from '/@/shared/components/text/text'; +import { PlayerType } from '/@/shared/types/types'; + +const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; + +const BAND_LABELS = [ + '31.5', + '63', + '125', + '250', + '500', + '1k', + '2k', + '3k', + '4k', + '6.3k', + '10k', + '16k', +]; + +const EQ_MIN = -12; +const EQ_MAX = 12; +const EQ_STEP = 0.5; + +// ─── Built-in EQ presets ────────────────────────────────────────────────────── +const EQ_PRESETS: Record = { + Acoustic: [2, 2, 3, 2, 1, 0, 1, 2, 2, 2, 2, 1], + 'Bass Boost': [6, 5, 4, 2, 1, 0, 0, 0, 0, 0, 0, 0], + 'Bass Cut': [-6, -5, -4, -2, -1, 0, 0, 0, 0, 0, 0, 0], + Classical: [0, 0, 0, 0, 0, 0, -1, -1, 0, 0, 0, -3], + Electronic: [4, 3, 1, 0, -1, 0, 1, 0, 0, 2, 3, 4], + Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + 'Hip-Hop': [5, 4, 2, 1, 0, -1, 0, 1, 0, 1, 2, 3], + Jazz: [2, 1, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2], + Loudness: [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 6], + Pop: [-1, 0, 2, 3, 3, 2, 0, -1, -1, 0, 0, 0], + Rock: [3, 2, 1, 0, -1, 0, 1, 2, 2, 2, 3, 3], + 'Treble Boost': [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6], + 'Treble Cut': [0, 0, 0, 0, 0, 0, -1, -2, -3, -4, -5, -6], + 'V-Shape': [5, 3, 1, 0, -1, -2, -2, -1, 0, 1, 3, 5], + 'Vocal Boost': [-1, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, -1], +}; + +// ─── Built-in compressor presets ───────────────────────────────────────────── +type CompressorPreset = Omit; +const COMP_PRESETS: Record = { + Broadcast: { attack: 15, knee: 3, makeup: 6, ratio: 5, release: 200, threshold: -20 }, + Default: { attack: 20, knee: 2.83, makeup: 6, ratio: 4, release: 250, threshold: -24 }, + Gentle: { attack: 50, knee: 6, makeup: 2, ratio: 1.5, release: 500, threshold: -15 }, + Heavy: { attack: 10, knee: 2, makeup: 8, ratio: 8, release: 150, threshold: -30 }, + Light: { attack: 30, knee: 4, makeup: 3, ratio: 2, release: 400, threshold: -18 }, + Limiter: { attack: 1, knee: 1, makeup: 0, ratio: 20, release: 100, threshold: -3 }, + 'Loud Master': { attack: 5, knee: 2, makeup: 10, ratio: 6, release: 100, threshold: -28 }, + Moderate: { attack: 20, knee: 3, makeup: 5, ratio: 4, release: 300, threshold: -24 }, +}; + +// ─── Storage helpers ────────────────────────────────────────────────────────── +const LS_EQ_PRESETS = 'feishin_eq_custom_presets'; +const LS_COMP_PRESETS = 'feishin_comp_custom_presets'; + +function loadCustomPresets(key: string): Record { + try { + return JSON.parse(localStorage.getItem(key) || '{}'); + } catch { + return {}; + } +} + +function saveCustomPresets(key: string, presets: Record) { + localStorage.setItem(key, JSON.stringify(presets)); +} + +// ─── Vertical EQ band slider ────────────────────────────────────────────────── +// Mantine v8 does not include orientation="vertical" on Slider. +// We use useMove from @mantine/hooks (the Mantine-recommended approach for +// vertical sliders in v8) so drag direction is correct — dragging up +// increases the value, dragging down decreases it. +// Styling uses the same CSS variables as the existing Slider module CSS +// so it inherits the app theme correctly. +const TRACK_H = 120; // px — rendered height of the vertical track +const THUMB_R = 6; // px — thumb radius + +function EqBandSlider({ + gain, + label, + onChangeEnd, +}: { + freq: number; + gain: number; + label: string; + onChangeEnd: (v: number) => void; +}) { + // currentGain drives the live visual during dragging. + // It is synced from the `gain` prop when external changes arrive + // (preset applied, reset). + const [currentGain, setCurrentGain] = useState(gain); + const currentGainRef = useRef(currentGain); + + // Stable ref so onScrubEnd always calls the latest onChangeEnd even + // though useMove's refCallback closes over the initial handlers object. + const onChangeEndRef = useRef(onChangeEnd); + + useEffect(() => { + setCurrentGain(gain); + currentGainRef.current = gain; + }, [gain]); + + useEffect(() => { + onChangeEndRef.current = onChangeEnd; + }, [onChangeEnd]); + + // handleMove must be stable (empty deps) so useMove's internal + // refCallback is only created once and listeners are not re-bound + // on every render. + const handleMove = useCallback(({ y }: { x: number; y: number }) => { + // useMove gives y=0 at the top of the element and y=1 at the bottom. + // Invert so dragging upward increases the value. + const raw = EQ_MAX - y * (EQ_MAX - EQ_MIN); + const stepped = Math.round(raw / EQ_STEP) * EQ_STEP; + const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, stepped)); + setCurrentGain(clamped); + currentGainRef.current = clamped; + }, []); + + const { active, ref } = useMove(handleMove, { + onScrubEnd: () => { + // Access ref so the latest onChangeEnd is called even though + // this handler was captured in the initial closure. + onChangeEndRef.current(currentGainRef.current); + }, + }); + + // Percentage from bottom: 0 = min (-12dB), 100 = max (+12dB) + const thumbPct = ((currentGain - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; + const zeroPct = ((0 - EQ_MIN) / (EQ_MAX - EQ_MIN)) * 100; // 50 for ±12 range + + // Fill spans between zero line and thumb, regardless of direction + const fillBottomPct = Math.min(thumbPct, zeroPct); + const fillTopPct = 100 - Math.max(thumbPct, zeroPct); + + return ( + + {/* Manual value input — keyed on gain prop so it remounts + when an external change arrives (preset, reset) */} + { + const val = parseFloat(e.currentTarget.value); + if (!isNaN(val)) { + const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val)); + setCurrentGain(clamped); + currentGainRef.current = clamped; + onChangeEndRef.current(clamped); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + size="xs" + step={EQ_STEP} + w={52} + /> + + {/* Vertical track — useMove attaches pointer listeners here */} +
+ {/* Coloured fill between the zero line and the thumb */} +
+ + {/* Zero-line tick mark */} +
+ + {/* Thumb — centre is at thumbPct% from the bottom */} +
+
+ + {/* Frequency label */} + + {label} + + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── +export const EqSettings = memo(() => { + const { t } = useTranslation(); + const settings = usePlaybackSettings(); + const { setSettings } = useSettingsStoreActions(); + + // Ref pattern to avoid stale closure when reading webAudio DSP nodes. + // webAudio?.dsp is undefined at callback creation time; the closure + // would capture that undefined even after AudioContext initialises. + const webAudioContext = useContext(WebAudioContext); + const webAudioContextRef = useRef(webAudioContext); + useEffect(() => { + webAudioContextRef.current = webAudioContext; + }, [webAudioContext]); + + // Custom preset state — stored in localStorage separately from main store + const [customEqPresets, setCustomEqPresets] = useState>(() => + loadCustomPresets(LS_EQ_PRESETS), + ); + const [customCompPresets, setCustomCompPresets] = useState>( + () => loadCustomPresets(LS_COMP_PRESETS), + ); + const [saveEqName, setSaveEqName] = useState(''); + const [saveCompName, setSaveCompName] = useState(''); + + const applyFilters = useCallback( + (eq: EqSettingsType, compressor: CompressorSettings) => { + // ── MPV player ──────────────────────────────────────────────── + if (settings.type === PlayerType.LOCAL) { + const filterStr = buildMpvAudioFilters(eq, compressor); + mpvPlayer?.setProperties({ af: filterStr }); + return; + } + + // ── Web Audio player ────────────────────────────────────────── + // Read from ref so we always get the current AudioContext state, + // not the stale value captured when this callback was created. + const dsp = webAudioContextRef.current.webAudio?.dsp; + if (!dsp) return; + + // Mutations to Web Audio API AudioParam values are intentional + // side effects on the live audio graph, not React state mutations. + // eslint-disable-next-line react-hooks/immutability + dsp.preampGain.gain.value = eq.enabled ? Math.pow(10, eq.preamp / 20) : 1; + + dsp.eqFilters.forEach((filter, i) => { + const band = eq.bands[i]; + if (band) { + filter.gain.value = eq.enabled ? band.gain : 0; + } + }); + + if (compressor.enabled) { + dsp.compressor.threshold.value = compressor.threshold; + dsp.compressor.ratio.value = compressor.ratio; + dsp.compressor.attack.value = compressor.attack / 1000; + dsp.compressor.release.value = compressor.release / 1000; + dsp.compressor.knee.value = compressor.knee; + } else { + dsp.compressor.threshold.value = 0; + dsp.compressor.ratio.value = 1; + dsp.compressor.attack.value = 0; + dsp.compressor.release.value = 0.25; + dsp.compressor.knee.value = 0; + } + }, + // settings.type is the only reactive dep — webAudioContextRef is a + // stable ref that always holds the latest context value. + [settings.type], + ); + + // Re-apply filters when switching to Web Audio so DSP nodes reflect + // persisted settings immediately without requiring a slider interaction. + useEffect(() => { + if (settings.type === PlayerType.WEB) { + applyFilters(settings.equalizer, settings.compressor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings.type]); + + // ── EQ handlers ────────────────────────────────────────────────────────── + const handleEqToggle = (enabled: boolean) => { + const newEq = { ...settings.equalizer, enabled }; + setSettings({ playback: { equalizer: newEq } }); + applyFilters(newEq, settings.compressor); + }; + + const handlePreampChangeEnd = (preamp: number) => { + const newEq = { ...settings.equalizer, preamp }; + setSettings({ playback: { equalizer: newEq } }); + applyFilters(newEq, settings.compressor); + }; + + const handleBandChangeEnd = (index: number, gain: number) => { + const newBands = settings.equalizer.bands.map((b, i) => (i === index ? { ...b, gain } : b)); + const newEq = { ...settings.equalizer, bands: newBands }; + setSettings({ playback: { equalizer: newEq } }); + applyFilters(newEq, settings.compressor); + }; + + const applyEqPreset = (gains: number[]) => { + const newBands = settings.equalizer.bands.map((b, i) => ({ ...b, gain: gains[i] ?? 0 })); + const newEq = { ...settings.equalizer, bands: newBands, preamp: 0 }; + setSettings({ playback: { equalizer: newEq } }); + applyFilters(newEq, settings.compressor); + }; + + const handleSaveEqPreset = () => { + const name = saveEqName.trim(); + if (!name) return; + const gains = settings.equalizer.bands.map((b) => b.gain); + const updated = { ...customEqPresets, [name]: gains }; + setCustomEqPresets(updated); + saveCustomPresets(LS_EQ_PRESETS, updated); + setSaveEqName(''); + }; + + const handleDeleteEqPreset = (name: string) => { + const updated = { ...customEqPresets }; + delete updated[name]; + setCustomEqPresets(updated); + saveCustomPresets(LS_EQ_PRESETS, updated); + }; + + const handleResetEq = () => { + const newEq = { + ...settings.equalizer, + bands: settings.equalizer.bands.map((b) => ({ ...b, gain: 0 })), + preamp: 0, + }; + setSettings({ playback: { equalizer: newEq } }); + applyFilters(newEq, settings.compressor); + }; + + // ── Compressor handlers ─────────────────────────────────────────────────── + const handleCompToggle = (enabled: boolean) => { + const newComp = { ...settings.compressor, enabled }; + setSettings({ playback: { compressor: newComp } }); + applyFilters(settings.equalizer, newComp); + }; + + const handleCompChangeEnd = (key: keyof CompressorSettings, value: number) => { + const newComp = { ...settings.compressor, [key]: value }; + setSettings({ playback: { compressor: newComp } }); + applyFilters(settings.equalizer, newComp); + }; + + const applyCompPreset = (preset: CompressorPreset) => { + const newComp = { ...settings.compressor, ...preset }; + setSettings({ playback: { compressor: newComp } }); + applyFilters(settings.equalizer, newComp); + }; + + const handleSaveCompPreset = () => { + const name = saveCompName.trim(); + if (!name) return; + const rest = Object.fromEntries( + Object.entries(settings.compressor).filter(([key]) => key !== 'enabled'), + ) as CompressorPreset; + const updated = { ...customCompPresets, [name]: rest }; + setCustomCompPresets(updated); + saveCustomPresets(LS_COMP_PRESETS, updated); + setSaveCompName(''); + }; + + const handleDeleteCompPreset = (name: string) => { + const updated = { ...customCompPresets }; + delete updated[name]; + setCustomCompPresets(updated); + saveCustomPresets(LS_COMP_PRESETS, updated); + }; + + const handleResetComp = () => { + const newComp = { + attack: 20, + enabled: settings.compressor.enabled, + knee: 2.83, + makeup: 6, + ratio: 4, + release: 250, + threshold: -24, + }; + setSettings({ playback: { compressor: newComp } }); + applyFilters(settings.equalizer, newComp); + }; + + // ── Preset select data ──────────────────────────────────────────────────── + const eqPresetSelectData = [ + { + group: t('setting.equalizerPresetGroupBuiltIn'), + items: Object.keys(EQ_PRESETS).map((name) => ({ label: name, value: name })), + }, + ...(Object.keys(customEqPresets).length > 0 + ? [ + { + group: t('setting.equalizerPresetGroupCustom'), + items: Object.keys(customEqPresets).map((name) => ({ + label: name, + value: name, + })), + }, + ] + : []), + ]; + + const compPresetSelectData = [ + { + group: t('setting.equalizerPresetGroupBuiltIn'), + items: Object.keys(COMP_PRESETS).map((name) => ({ label: name, value: name })), + }, + ...(Object.keys(customCompPresets).length > 0 + ? [ + { + group: t('setting.equalizerPresetGroupCustom'), + items: Object.keys(customCompPresets).map((name) => ({ + label: name, + value: name, + })), + }, + ] + : []), + ]; + + // ── EQ SettingsSection options ──────────────────────────────────────────── + const eqOptions: SettingOption[] = [ + { + control: ( + handleEqToggle(e.currentTarget.checked)} + /> + ), + description: + settings.type === PlayerType.LOCAL + ? t('setting.equalizer', { context: 'descriptionMpv' }) + : t('setting.equalizer', { context: 'descriptionWebAudio' }), + title: t('setting.equalizer'), + }, + ...(settings.equalizer.enabled + ? ([ + { + control: ( + + ({ + label: name, + value: name, + }))} + onChange={(name) => { + if (!name) return; + handleDeleteEqPreset(name); + }} + placeholder={t('setting.equalizerPresetDeletePlaceholder')} + value={null} + w={160} + /> + )} + + ), + description: t('setting.equalizerPreset', { context: 'description' }), + title: t('setting.equalizerPreset'), + }, + { + control: ( + + setSaveEqName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEqPreset(); + }} + placeholder={t('setting.equalizerPresetNamePlaceholder')} + value={saveEqName} + w={180} + /> + + + ), + description: t('setting.equalizerSavePreset', { context: 'description' }), + title: t('setting.equalizerSavePreset'), + }, + { + control: ( + + `${v > 0 ? '+' : ''}${v} dB`} + max={EQ_MAX} + min={EQ_MIN} + onChange={(v) => { + setSettings({ + playback: { + equalizer: { ...settings.equalizer, preamp: v }, + }, + }); + }} + onChangeEnd={handlePreampChangeEnd} + step={EQ_STEP} + value={settings.equalizer.preamp} + w={200} + /> + {/* Manual preamp input */} + { + const val = parseFloat(e.currentTarget.value); + if (!isNaN(val)) { + const clamped = Math.max(EQ_MIN, Math.min(EQ_MAX, val)); + handlePreampChangeEnd(clamped); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + rightSection={ + + dB + + } + size="sm" + step={EQ_STEP} + value={settings.equalizer.preamp} + w={70} + /> + + + ), + description: t('setting.equalizerPreamp', { context: 'description' }), + title: t('setting.equalizerPreamp'), + }, + { + control: ( + // EqBandSlider uses useMove for correct vertical drag direction + // (up = higher value, down = lower value). The NumberInput above + // each band allows precise manual entry. + + {settings.equalizer.bands.map((band, i) => ( + handleBandChangeEnd(i, v)} + /> + ))} + + ), + description: t('setting.equalizerBands', { context: 'description' }), + title: t('setting.equalizerBands'), + }, + ] as SettingOption[]) + : []), + ]; + + // ── Compressor param definitions ────────────────────────────────────────── + const compParams: { + description: string; + key: keyof CompressorSettings; + max: number; + min: number; + step: number; + title: string; + unit: string; + }[] = [ + { + description: t('setting.compressorThreshold', { context: 'description' }), + key: 'threshold', + max: 0, + min: -60, + step: 1, + title: t('setting.compressorThreshold'), + unit: 'dB', + }, + { + description: t('setting.compressorRatio', { context: 'description' }), + key: 'ratio', + max: 20, + min: 1, + step: 0.5, + title: t('setting.compressorRatio'), + unit: ':1', + }, + { + description: t('setting.compressorAttack', { context: 'description' }), + key: 'attack', + max: 2000, + min: 0.1, + step: 1, + title: t('setting.compressorAttack'), + unit: 'ms', + }, + { + description: t('setting.compressorRelease', { context: 'description' }), + key: 'release', + max: 9000, + min: 1, + step: 10, + title: t('setting.compressorRelease'), + unit: 'ms', + }, + { + description: t('setting.compressorMakeupGain', { context: 'description' }), + key: 'makeup', + max: 30, + min: 0, + step: 0.5, + title: t('setting.compressorMakeupGain'), + unit: 'dB', + }, + { + description: t('setting.compressorKnee', { context: 'description' }), + key: 'knee', + max: 10, + min: 1, + step: 0.5, + title: t('setting.compressorKnee'), + unit: 'dB', + }, + ]; + + // ── Compressor SettingsSection options ──────────────────────────────────── + const compressorOptions: SettingOption[] = [ + { + control: ( + handleCompToggle(e.currentTarget.checked)} + /> + ), + description: + settings.type === PlayerType.LOCAL + ? t('setting.compressor', { context: 'descriptionMpv' }) + : t('setting.compressor', { context: 'descriptionWebAudio' }), + title: t('setting.compressor'), + }, + ...(settings.compressor.enabled + ? ([ + { + control: ( + + ({ + label: name, + value: name, + }))} + onChange={(name) => { + if (!name) return; + handleDeleteCompPreset(name); + }} + placeholder={t('setting.equalizerPresetDeletePlaceholder')} + value={null} + w={160} + /> + )} + + ), + description: t('setting.compressorPreset', { context: 'description' }), + title: t('setting.equalizerPreset'), + }, + { + control: ( + + setSaveCompName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveCompPreset(); + }} + placeholder={t('setting.equalizerPresetNamePlaceholder')} + value={saveCompName} + w={180} + /> + + + ), + description: t('setting.compressorSavePreset', { context: 'description' }), + title: t('setting.equalizerSavePreset'), + }, + // One SettingOption per compressor parameter — Slider + NumberInput + ...compParams.map(({ description, key, max, min, step, title, unit }) => ({ + control: ( + + `${v}${unit}`} + max={max} + min={min} + onChange={(v) => { + setSettings({ + playback: { + compressor: { ...settings.compressor, [key]: v }, + }, + }); + }} + onChangeEnd={(v) => handleCompChangeEnd(key, v)} + step={step} + value={settings.compressor[key] as number} + w={200} + /> + {/* Manual value input — remounts with new defaultValue + when settings change (preset applied, slider moved) */} + { + const val = parseFloat(e.currentTarget.value); + if (!isNaN(val)) { + const clamped = Math.max(min, Math.min(max, val)); + handleCompChangeEnd(key, clamped); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + rightSection={ + + {unit} + + } + size="sm" + step={step} + value={settings.compressor[key] as number} + w={80} + /> + + ), + description, + title, + })), + { + control: ( + + ), + description: t('setting.compressorReset', { context: 'description' }), + title: t('common.reset'), + }, + ] as SettingOption[]) + : []), + ]; + + return ( + <> + + + + + + ); +}); diff --git a/src/renderer/features/settings/components/playback/mpv-audio-filters.ts b/src/renderer/features/settings/components/playback/mpv-audio-filters.ts new file mode 100644 index 000000000..7c581541a --- /dev/null +++ b/src/renderer/features/settings/components/playback/mpv-audio-filters.ts @@ -0,0 +1,82 @@ +// Builds the MPV `af` audio filter chain string for EQ and compressor. +// Uses FFmpeg lavfi filters, which MPV exposes natively. + +export interface CompressorSettings { + attack: number; // ms + enabled: boolean; + knee: number; // dB soft-knee width + makeup: number; // dB post-compression gain + ratio: number; // e.g. 4 (means 4:1) + release: number; // ms + threshold: number; // dB, e.g. -24 +} + +export interface EqBand { + freq: number; + gain: number; // dB, clamped to [-12, 12] +} + +export interface EqSettings { + bands: EqBand[]; + enabled: boolean; + preamp: number; // dB pre-gain before bands, clamped to [-12, 12] +} + +// Octave widths for each band — tuned so 10 bands cover 20Hz–20kHz +// with no gaps and gentle overlap. +const BAND_WIDTHS: Record = { + 31.5: 1.9, + 63: 1.3, + 125: 1.0, + 250: 1.0, + 500: 1.0, + 1000: 1.0, + 2000: 1.0, + 3000: 1.0, + 4000: 1.0, + 6300: 1.2, + 10000: 1.2, + 16000: 1.5, +}; + +/** + * Returns the MPV `af` property value for the given EQ + compressor settings. + * An empty string clears all filters (pass-through). + */ +export function buildMpvAudioFilters(eq: EqSettings, compressor: CompressorSettings): string { + const parts: string[] = []; + + if (eq.enabled) { + // Apply preamp as a straight input gain before the band filters. + // The user is responsible for setting a negative preamp value when + // boosting bands to avoid clipping — matching the behaviour of VLC, + // foobar2000, and hardware EQs. The UI preamp slider exists for this purpose. + if (eq.preamp !== 0) { + parts.push(`volume=${eq.preamp}dB`); + } + + // One parametric EQ filter per non-zero band + for (const band of eq.bands) { + if (band.gain === 0) continue; + const w = BAND_WIDTHS[band.freq] ?? 1.0; + parts.push(`lavfi=[equalizer=f=${band.freq}:width_type=o:w=${w}:g=${band.gain}]`); + } + } + + if (compressor.enabled) { + const threshLinear = Math.pow(10, compressor.threshold / 20); + const makeupLinear = Math.pow(10, compressor.makeup / 20); + parts.push( + `lavfi=[acompressor=` + + `threshold=${threshLinear.toFixed(6)}:` + + `ratio=${compressor.ratio}:` + + `attack=${compressor.attack}:` + + `release=${compressor.release}:` + + `makeup=${makeupLinear.toFixed(6)}:` + + `knee=${compressor.knee}` + + `]`, + ); + } + + return parts.join(','); +} diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index 71f87696b..ac5cffa5b 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow'; import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings'; import { AutoDJSettings } from '/@/renderer/features/settings/components/playback/auto-dj-settings'; +import { EqSettings } from '/@/renderer/features/settings/components/playback/eq-settings'; import { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings'; import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings'; import { useSettingsStore } from '/@/renderer/store'; @@ -37,6 +38,7 @@ export const PlaybackTab = memo(() => { }>{hasFancyAudio && } + diff --git a/src/renderer/features/settings/components/window/window-settings.tsx b/src/renderer/features/settings/components/window/window-settings.tsx index 8b8168f55..72b4fffcb 100644 --- a/src/renderer/features/settings/components/window/window-settings.tsx +++ b/src/renderer/features/settings/components/window/window-settings.tsx @@ -63,6 +63,30 @@ export const WindowSettings = memo(() => { isHidden: !isElectron(), title: t('setting.windowBarStyle'), }, + { + control: ( + { + if (!e) return; + setSettings({ + window: { + windowBarTrackinfo: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.windowBarTrackinfo', { + context: 'description', + }), + // tab is hidden entirely right now + // but if it was shown we would want to show this option + // as it also controls the tab title in web + isHidden: false, + title: t('setting.windowBarTrackinfo'), + }, { control: ( = [ + [AlbumGroupItem.ALBUM_ARTISTS, 'table.column.albumArtist'], + [AlbumGroupItem.RELEASE_DATE, 'table.column.releaseDate'], + [AlbumGroupItem.RELEASE_YEAR, 'table.column.releaseYear'], + [AlbumGroupItem.SONG_COUNT, 'table.column.songCount'], + [AlbumGroupItem.DURATION, 'common.duration'], + [AlbumGroupItem.RELEASE_TYPE, 'common.releaseType'], + [AlbumGroupItem.GENRES, 'table.column.genre'], + [AlbumGroupItem.SIZE, 'table.column.size'], +]; + +const mergeItems = >( + items: T[], + itemLabels: Array<[K, string]>, +): T[] => { + const allItemIds = itemLabels.map(([key]) => key); + + const missingItemIds = allItemIds.filter((id) => !items.some((item) => item.id === id)); + + const merged = [ + ...items, + ...(missingItemIds.map((id) => ({ + disabled: true, + id, + })) as T[]), + ]; + + const uniqueMerged = merged.filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id), + ); + + return uniqueMerged.filter((item) => itemLabels.some(([key]) => key === item.id)); +}; + +export const AlbumGroupMetadataConfig = memo(() => { + const { t } = useTranslation(); + const albumGroupItems = useAlbumGroupItems(); + const { setAlbumGroupItems } = useSettingsStoreActions(); + + const items = useMemo( + () => + mergeItems(albumGroupItems as SortableItem[], ALBUM_GROUP_ITEM_LABELS), + [albumGroupItems], + ); + + const translatedItemMap = useMemo( + () => + Object.fromEntries( + ALBUM_GROUP_ITEM_LABELS.map(([key, value]) => [key, t(value)]), + ) as Record, + [t], + ); + + const handleChangeDisabled = useCallback( + (id: string, enabled: boolean) => { + setAlbumGroupItems( + items.map((item) => { + if (item.id === id) { + return { + ...item, + disabled: !enabled, + }; + } + + return item; + }), + ); + }, + [items, setAlbumGroupItems], + ); + + const handleReorder = useCallback( + (reorderedItems: SortableItem[]) => { + setAlbumGroupItems(reorderedItems); + }, + [setAlbumGroupItems], + ); + + return ( + + + + {t('table.config.general.albumGroupMetadata')} + + + + {items.map((item) => ( + + ))} + + + ); +}); diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index 64fea0155..647c644ad 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -823,18 +823,38 @@ const GENRE_LIST_FILTERS: Partial< }, ], [ServerType.NAVIDROME]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumCount'), + value: GenreListSort.ALBUM_COUNT, + }, { defaultOrder: SortOrder.ASC, name: i18n.t('filter.name'), value: GenreListSort.NAME, }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.songCount'), + value: GenreListSort.SONG_COUNT, + }, ], [ServerType.SUBSONIC]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumCount'), + value: GenreListSort.ALBUM_COUNT, + }, { defaultOrder: SortOrder.ASC, name: i18n.t('filter.name'), value: GenreListSort.NAME, }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumCount'), + value: GenreListSort.SONG_COUNT, + }, ], }; diff --git a/src/renderer/features/shared/components/table-config.tsx b/src/renderer/features/shared/components/table-config.tsx index 8605dcf49..23eca1952 100644 --- a/src/renderer/features/shared/components/table-config.tsx +++ b/src/renderer/features/shared/components/table-config.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import styles from './table-config.module.css'; import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; +import { AlbumGroupMetadataConfig } from '/@/renderer/features/shared/components/album-group-metadata-config'; import { ListConfigBooleanControl, ListConfigTable, @@ -29,6 +30,7 @@ import { } from '/@/renderer/store'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; +import { Button } from '/@/shared/components/button/button'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; @@ -41,7 +43,7 @@ import { Text } from '/@/shared/components/text/text'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { useDebouncedState } from '/@/shared/hooks/use-debounced-state'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; -import { ItemListKey, ListPaginationType } from '/@/shared/types/types'; +import { ItemListKey, ListPaginationType, TableColumn } from '/@/shared/types/types'; interface TableConfigProps { enablePinColumnButtons?: boolean; @@ -72,10 +74,21 @@ export const TableConfig = ({ const { t } = useTranslation(); const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings; - const { setList } = useSettingsStoreActions(); + const albumGroupImageSize = useSettingsStore((state) => state.general.albumGroupImageSize); + const albumGroupShowFavoriteRating = useSettingsStore( + (state) => state.general.albumGroupShowFavoriteRating, + ); + const imageResTable = useSettingsStore((state) => state.general.imageRes.table); + const { setList, setSettings } = useSettingsStoreActions(); + const [albumGroupOpen, setAlbumGroupOpen] = useState(false); const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table; + const hasAlbumGroupColumn = useMemo( + () => table.columns.some((column) => column.id === TableColumn.ALBUM_GROUP), + [table.columns], + ); + const setTableUpdate = useCallback( (patch: Partial) => { if (tableKey === 'detail') { @@ -90,6 +103,93 @@ export const TableConfig = ({ ); const advancedSettings = useMemo(() => { + const albumGroupOptions = + hasAlbumGroupColumn && tableKey === 'main' + ? [ + { + component: ( + + + + ), + id: 'albumGroupConfig', + label: t('table.config.general.albumGroupConfig'), + }, + ...(albumGroupOpen + ? [ + { + component: ( + + { + const size = Math.max( + 0, + Math.min( + 2000, + typeof value === 'number' ? value : 0, + ), + ); + setSettings({ + general: { + albumGroupImageSize: size, + // Source table art must be at least as + // large as the displayed album image. + ...(size >= imageResTable + ? { imageRes: { table: size } } + : {}), + }, + }); + }} + rightSection={ + + px + + } + value={albumGroupImageSize} + width={90} + /> + + ), + id: 'albumImageSize', + label: ( + + {t('table.config.general.albumImageSize')} + + ), + }, + { + component: ( + + setSettings({ + general: { + albumGroupShowFavoriteRating: value, + }, + }) + } + value={albumGroupShowFavoriteRating} + /> + ), + id: 'albumGroupShowFavoriteRating', + label: ( + + {t('table.config.general.albumGroupShowFavoriteRating')} + + ), + }, + ] + : []), + ] + : []; + const allOptions = [ { component: ( @@ -238,6 +338,7 @@ export const TableConfig = ({ id: 'autoFitColumns', label: t('table.config.general.autoFitColumns'), }, + ...albumGroupOptions, ...(extraOptions || []), ]; @@ -262,11 +363,20 @@ export const TableConfig = ({ listKey, setTableUpdate, optionsConfig, + hasAlbumGroupColumn, + albumGroupOpen, + albumGroupImageSize, + albumGroupShowFavoriteRating, + imageResTable, + setSettings, ]); return ( <> + {hasAlbumGroupColumn && tableKey === 'main' && albumGroupOpen && ( + + )} (defaultValue: null | string, listKey: ItemListKey) => { +export const useSortByFilter = (defaultValue: string, listKey: ItemListKey) => { const server = useCurrentServer(); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx index 3075b8ed0..afbb510fe 100644 --- a/src/renderer/features/sharing/components/share-item-context-modal.tsx +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -43,8 +43,18 @@ export const ShareItemContextModal = ({ }); const handleSubmit = form.onSubmit(async (values) => { - shareItemMutation.mutate( - { + const canUseClipboard = Boolean(navigator.clipboard) && window.isSecureContext; + + // The share URL only exists once the create request resolves. Calling + // navigator.clipboard.writeText() from that async callback runs outside + // the click's user activation, so Firefox/Safari reject it ("Clipboard + // write was blocked due to lack of user activation") and nothing is + // copied. Instead, call clipboard.write() synchronously within this + // gesture with a ClipboardItem whose value is a promise that resolves to + // the URL — this preserves the activation while the share is created. + // Falls back to writeText, then to the "click to open" toast. + const shareUrlPromise = shareItemMutation + .mutateAsync({ apiClientProps: { serverId: server?.id || '' }, body: { description: values.description, @@ -53,51 +63,63 @@ export const ShareItemContextModal = ({ resourceIds: itemIds.join(), resourceType, }, + }) + .then((data) => { + if (!server) throw new Error('Server not found'); + if (!data?.id) throw new Error('Failed to share item'); + + const serverUrl = getServerUrl(server, true); + if (!serverUrl) throw new Error('Server URL not found'); + return `${serverUrl}/share/${data.id}`; + }); + + let copied = false; + if (canUseClipboard) { + try { + if (typeof ClipboardItem !== 'undefined') { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': shareUrlPromise.then( + (url) => new Blob([url], { type: 'text/plain' }), + ), + }), + ]); + } else { + await navigator.clipboard.writeText(await shareUrlPromise); + } + copied = true; + } catch { + copied = false; + } + } + + let shareUrl: string; + try { + shareUrl = await shareUrlPromise; + } catch { + toast.error({ + message: t('form.shareItem.createFailed'), + }); + closeModal(id); + return; + } + + toast.success({ + autoClose: copied ? 5000 : 15000, + id: 'share-item-toast', + message: t(copied ? 'form.shareItem.success' : 'form.shareItem.successMustClick', {}), + onClick: (a) => { + if (!(a.target instanceof HTMLElement)) return; + + // Make sure we weren't clicking close (otherwise clicking close /also/ opens the url) + if (a.target.nodeName !== 'svg') { + window.open(shareUrl); + toast.hide('share-item-toast'); + } }, - { - onError: () => { - toast.error({ - message: t('form.shareItem.createFailed'), - }); - }, - onSuccess: (_data) => { - if (!server) throw new Error('Server not found'); - if (!_data?.id) throw new Error('Failed to share item'); - - const serverUrl = getServerUrl(server, true); - if (!serverUrl) throw new Error('Server URL not found'); - const shareUrl = `${serverUrl}/share/${_data.id}`; - - const canUseClipboard = navigator.clipboard && window.isSecureContext; - if (canUseClipboard) { - navigator.clipboard.writeText(shareUrl); - } - - toast.success({ - autoClose: canUseClipboard ? 5000 : 15000, - id: 'share-item-toast', - message: t( - canUseClipboard - ? 'form.shareItem.success' - : 'form.shareItem.successMustClick', - {}, - ), - onClick: (a) => { - if (!(a.target instanceof HTMLElement)) return; - - // Make sure we weren't clicking close (otherwise clicking close /also/ opens the url) - if (a.target.nodeName !== 'svg') { - window.open(shareUrl); - toast.hide('share-item-toast'); - } - }, - }); - }, - }, - ); + }); closeModal(id); - return null; }); return ( diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css index d8d8bbed5..7134c543e 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.module.css @@ -136,6 +136,10 @@ white-space: nowrap; } +.name-active { + color: var(--theme-colors-primary); +} + .image-container { flex-shrink: 0; width: 3rem; diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index a31c4153e..69b9a2695 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -28,6 +28,7 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor'; import { AppRoute } from '/@/renderer/router/routes'; import { + useCurrentPlaylistContextId, useCurrentServer, useCurrentServerId, usePermissions, @@ -116,6 +117,8 @@ export const PlaylistRowButton = memo( const sidebarPlaylistSorting = useSidebarPlaylistSorting(); const sidebarPlaylistMode = useSidebarPlaylistMode(); const isCompact = sidebarPlaylistMode === 'compact'; + const activePlaylistId = useCurrentPlaylistContextId(); + const isActive = activePlaylistId === item.id; const [isHovered, setIsHovered] = useState(false); const isSmartPlaylist = Boolean(item.rules); @@ -292,7 +295,13 @@ export const PlaylistRowButton = memo( > {isCompact ? ( <> - + {name} {isHovered && ( @@ -307,7 +316,13 @@ export const PlaylistRowButton = memo(
- + {name}
diff --git a/src/renderer/features/songs/components/song-list-infinite-grid.tsx b/src/renderer/features/songs/components/song-list-infinite-grid.tsx index b8a2788a7..2b8790e23 100644 --- a/src/renderer/features/songs/components/song-list-infinite-grid.tsx +++ b/src/renderer/features/songs/components/song-list-infinite-grid.tsx @@ -34,16 +34,23 @@ export const SongListInfiniteGrid = ({ const listQueryFn = api.controller.getSongList; const { pageKey } = useListContext(); - const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = - useItemListInfiniteLoader({ - eventKey: pageKey || ItemListKey.SONG, - itemsPerPage, - itemType: LibraryItem.SONG, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { + dataVersion, + getItem, + getItemIndex, + getLoadedItems, + itemCount, + loadedItems, + onRangeChanged, + } = useItemListInfiniteLoader({ + eventKey: pageKey || ItemListKey.SONG, + itemsPerPage, + itemType: LibraryItem.SONG, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -60,6 +67,7 @@ export const SongListInfiniteGrid = ({ gap={gap} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/features/songs/components/song-list-infinite-table.tsx b/src/renderer/features/songs/components/song-list-infinite-table.tsx index 1a259f840..7ae9cc31f 100644 --- a/src/renderer/features/songs/components/song-list-infinite-table.tsx +++ b/src/renderer/features/songs/components/song-list-infinite-table.tsx @@ -42,7 +42,7 @@ export const SongListInfiniteTable = ({ const listQueryFn = api.controller.getSongList; const { pageKey } = useListContext(); - const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + const { getItem, getItemIndex, getLoadedItems, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: pageKey || ItemListKey.SONG, itemsPerPage, @@ -83,6 +83,7 @@ export const SongListInfiniteTable = ({ enableVerticalBorders={enableVerticalBorders} getItem={getItem} getItemIndex={getItemIndex} + getLoadedItems={getLoadedItems} initialTop={{ to: scrollOffset ?? 0, type: 'offset', diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 1e69d8c75..414b9151c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -68,6 +68,10 @@ declare global { FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string; FS_LYRICS_FETCH?: string; FS_LYRICS_FOLLOW?: string; + FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT?: string; + FS_LYRICS_LINE_LEAD_TIME_MS?: string; + FS_LYRICS_PADDING_LEFT?: string; + FS_LYRICS_PADDING_RIGHT?: string; FS_LYRICS_PREFER_LOCAL?: string; FS_LYRICS_SHOW_MATCH?: string; FS_LYRICS_SHOW_PROVIDER?: string; diff --git a/src/renderer/layouts/window-bar.tsx b/src/renderer/layouts/window-bar.tsx index 3e869122e..f196376ba 100644 --- a/src/renderer/layouts/window-bar.tsx +++ b/src/renderer/layouts/window-bar.tsx @@ -14,7 +14,13 @@ import macMin from './assets/min-mac.png'; import styles from './window-bar.module.css'; import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player'; -import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store'; +import { + useAppStore, + usePlayerData, + usePlayerStatus, + useWindowBarTrackinfo, + useWindowSettings, +} from '/@/renderer/store'; import { Text } from '/@/shared/components/text/text'; import { Platform, PlayerStatus } from '/@/shared/types/types'; @@ -130,6 +136,8 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => { export const WindowBar = () => { const { t } = useTranslation(); const { windowBarStyle } = useWindowSettings(); + const windowBarTrackinfo = useWindowBarTrackinfo(); + const playerStatus = usePlayerStatus(); const privateMode = useAppStore((state) => state.privateMode); const handleMinimize = () => minimize(); @@ -153,6 +161,10 @@ export const WindowBar = () => { const title = useMemo(() => { const privateModeString = privateMode ? t('page.windowBar.privateMode') : ''; + if (!windowBarTrackinfo) { + return `Feishin${privateMode ? ` ${privateModeString}` : ''}`; + } + // Show radio information if radio is active if (isRadioActive) { const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : ''; @@ -194,6 +206,7 @@ export const WindowBar = () => { queueLength, stationName, t, + windowBarTrackinfo, ]); useEffect(() => { diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index 3cf1bb6f3..f97a8f134 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -2,12 +2,11 @@ import { useEffect, useMemo } from 'react'; import { Navigate, Outlet } from 'react-router'; import { shallow } from 'zustand/shallow'; +import { normalizeServerUrl } from '/@/renderer/features/action-required/utils/server-lock'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { AppRoute } from '/@/renderer/router/routes'; import { useAuthStore, useAuthStoreActions } from '/@/renderer/store'; -const normalizeUrl = (url: string) => url.replace(/\/$/, ''); - export const AppOutlet = () => { const currentServer = useAuthStore( (state) => @@ -19,25 +18,27 @@ export const AppOutlet = () => { : null, shallow, ); - const { deleteServer, setCurrentServer } = useAuthStoreActions(); + const { setCurrentServer, updateServer } = useAuthStoreActions(); const hasServerLockMismatch = useMemo(() => { if (!isServerLock() || !currentServer || !window.SERVER_URL) { return false; } - const configuredUrl = normalizeUrl(window.SERVER_URL); - const persistedUrl = normalizeUrl(currentServer.url); + const configuredUrl = normalizeServerUrl(window.SERVER_URL); + const persistedUrl = normalizeServerUrl(currentServer.url); return configuredUrl !== persistedUrl; }, [currentServer]); useEffect(() => { - if (hasServerLockMismatch && currentServer) { - deleteServer(currentServer.id); + if (hasServerLockMismatch && currentServer && window.SERVER_URL) { + updateServer(currentServer.id, { + url: normalizeServerUrl(window.SERVER_URL), + }); setCurrentServer(null); } - }, [currentServer, deleteServer, hasServerLockMismatch, setCurrentServer]); + }, [currentServer, hasServerLockMismatch, setCurrentServer, updateServer]); const isActionsRequired = !currentServer || hasServerLockMismatch; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index bffcc4263..c4937cb85 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -202,7 +202,7 @@ const appRouterModals = { export const AppRouter = () => { const router = ( - + diff --git a/src/renderer/store/env-settings-overrides.ts b/src/renderer/store/env-settings-overrides.ts index 792e0b901..4887ddb5a 100644 --- a/src/renderer/store/env-settings-overrides.ts +++ b/src/renderer/store/env-settings-overrides.ts @@ -115,7 +115,10 @@ const AUTO_DJ_MODES = new Set(['albums', 'songs']); const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']); export type EnvSettingsOverrides = DeepPartial< - Pick + Pick< + SettingsState, + 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'lyricsDisplay' | 'playback' + > >; type DeepPartial = { @@ -403,6 +406,16 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ { key: 'FS_LYRICS_FETCH', path: ['lyrics', 'fetch'], type: 'bool' }, { key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' }, { key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' }, + { key: 'FS_LYRICS_LINE_LEAD_TIME_MS', path: ['lyrics', 'lineLeadTimeMs'], type: 'num' }, + { + key: 'FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT', + path: ['lyrics', 'followScrollAlignment'], + transform: (s) => { + const n = parseNum(s); + return n !== undefined ? Math.min(50, Math.max(-50, Math.round(n))) : undefined; + }, + type: 'num', + }, { key: 'FS_LYRICS_PREFER_LOCAL', path: ['lyrics', 'preferLocalLyrics'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' }, @@ -424,6 +437,24 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ path: ['lyrics', 'alignment'], type: 'enum', }, + { + key: 'FS_LYRICS_PADDING_LEFT', + path: ['lyricsDisplay', 'default', 'paddingLeft'], + transform: (s) => { + const n = parseNum(s); + return n !== undefined ? Math.min(20, Math.max(0, Math.round(n))) : undefined; + }, + type: 'num', + }, + { + key: 'FS_LYRICS_PADDING_RIGHT', + path: ['lyricsDisplay', 'default', 'paddingRight'], + transform: (s) => { + const n = parseNum(s); + return n !== undefined ? Math.min(20, Math.max(0, Math.round(n))) : undefined; + }, + type: 'num', + }, { enumSet: AUTO_DJ_STRATEGIES, key: 'FS_AUTO_DJ_ALBUM_STRATEGY', diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 06ea44b33..6444a2a2e 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -207,25 +207,25 @@ function calculateNextIndex( currentIndex: number, queueLength: number, repeat: PlayerRepeat, -): { nextIndex: number; shouldPause: boolean } { +): { nextIndex: number; shouldStop: boolean } { const isLastTrack = currentIndex === queueLength - 1; if (repeat === PlayerRepeat.ONE) { // Repeat one: stay on the same track - return { nextIndex: currentIndex, shouldPause: false }; + return { nextIndex: currentIndex, shouldStop: false }; } else if (repeat === PlayerRepeat.ALL) { // Repeat all: loop to first track if at the end if (isLastTrack) { - return { nextIndex: 0, shouldPause: false }; + return { nextIndex: 0, shouldStop: false }; } else { - return { nextIndex: currentIndex + 1, shouldPause: false }; + return { nextIndex: currentIndex + 1, shouldStop: false }; } } else { - // Repeat none: move to next track, or pause if at the end + // Repeat none: move to next track, or stop if at the end if (isLastTrack) { - return { nextIndex: currentIndex, shouldPause: true }; + return { nextIndex: currentIndex, shouldStop: true }; } else { - return { nextIndex: currentIndex + 1, shouldPause: false }; + return { nextIndex: currentIndex + 1, shouldStop: false }; } } } @@ -292,6 +292,19 @@ function emitPlayerPlayEvent( } } +function emitPlayerStop(get: () => PlayerState, reset: boolean): void { + const currentState = get(); + const queue = currentState.getQueue(); + const currentIndex = currentState.player.index; + const currentSong = queue.items[currentIndex]; + + eventEmitter.emit('PLAYER_STOP', { + id: currentSong?._uniqueId, + index: currentIndex !== undefined && currentIndex >= 0 ? currentIndex : undefined, + reset, + }); +} + // Helper function to find shuffled position for a given queue index function findShuffledPositionForQueueIndex( queueIndex: number, @@ -921,11 +934,12 @@ export const usePlayerStoreBase = createWithEqualityFn()( ? stateSnapshot.queue.shuffled.length : queue.items.length; - const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( + const { nextIndex: nextPlaybackIndex, shouldStop } = calculateNextIndex( currentIndex, playbackLength, repeat, ); + const isRepeatOneSameTrack = repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex; // Dual web players alternate for gapless/crossfade between tracks. Repeat-one @@ -937,9 +951,12 @@ export const usePlayerStoreBase = createWithEqualityFn()( ? 2 : 1; const pauseOnNext = player.pauseOnNextSongEnd; - const newStatus = - shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; - const shouldKeepCurrentPlayer = newStatus === PlayerStatus.PAUSED; + const newStatus = shouldStop + ? PlayerStatus.STOPPED + : pauseOnNext + ? PlayerStatus.PAUSED + : PlayerStatus.PLAYING; + const shouldKeepCurrentPlayer = newStatus !== PlayerStatus.PLAYING; const shouldSwapPlayer = !isRepeatOneSameTrack && !shouldKeepCurrentPlayer; set((state) => { @@ -948,11 +965,19 @@ export const usePlayerStoreBase = createWithEqualityFn()( setTimestampStore(0); state.player.status = newStatus; + if (shouldStop) { + state.player.seekToTimestamp = uniqueSeekToTimestamp(0); + } + if (pauseOnNext) { state.player.pauseOnNextSongEnd = false; } }); + if (shouldStop) { + emitPlayerStop(get, true); + } + if (repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex) { eventEmitter.emit('PLAYER_REPEATED', { index: nextPlaybackIndex, @@ -1023,21 +1048,45 @@ export const usePlayerStoreBase = createWithEqualityFn()( const state = get(); const currentIndex = state.player.index; const player = state.player; + const repeat = player.repeat; + const isShuffle = isShuffleEnabled(state); const queue = state.getQueueOrder(); - const isLastTrack = currentIndex === queue.items.length - 1; + const playbackLength = isShuffle + ? state.queue.shuffled.length + : queue.items.length; - let nextIndex: number; + if (repeat === PlayerRepeat.ONE) { + // Manual next while repeat-one is active should still advance in the queue. + const nextIndex = Math.min(playbackLength - 1, currentIndex + 1); - if (player.repeat === PlayerRepeat.ALL && isLastTrack) { - // Repeat all: wrap to first track when on last track - nextIndex = 0; - } else if (player.repeat === PlayerRepeat.NONE && isLastTrack) { - // Repeat none: stay on last track if already there - nextIndex = currentIndex; - } else { - // Otherwise, advance to next track (including repeat ONE for manual navigation) - // When shuffle is enabled, currentIndex is already the position in the shuffled array - nextIndex = Math.min(queue.items.length - 1, currentIndex + 1); + set((state) => { + state.player.index = nextIndex; + state.player.playerNum = 1; + setTimestampStore(0); + }); + + eventEmitter.emit('MEDIA_NEXT', { + currentIndex, + nextIndex, + }); + return; + } + + const { nextIndex, shouldStop } = calculateNextIndex( + currentIndex, + playbackLength, + repeat, + ); + + if (shouldStop) { + set((state) => { + state.player.status = PlayerStatus.STOPPED; + state.player.playerNum = 1; + setTimestampStore(0); + state.player.seekToTimestamp = uniqueSeekToTimestamp(0); + }); + emitPlayerStop(get, true); + return; } set((state) => { @@ -1235,12 +1284,14 @@ export const usePlayerStoreBase = createWithEqualityFn()( mediaStop: (options?: { reset?: boolean }) => { const reset = options?.reset !== false; set((state) => { - state.player.status = PlayerStatus.PAUSED; + state.player.status = PlayerStatus.STOPPED; setTimestampStore(0); if (reset) { state.player.seekToTimestamp = uniqueSeekToTimestamp(0); } }); + + emitPlayerStop(get, reset); }, mediaToggleMute: () => { set((state) => { @@ -1248,6 +1299,11 @@ export const usePlayerStoreBase = createWithEqualityFn()( }); }, mediaTogglePlayPause: () => { + // Restarting from STOPPED (e.g. end of queue) needs a full play + // event so engines like mpv can reload the current track — play() + // alone is a no-op when mpv's playlist-pos is -1. + const wasStopped = get().player.status === PlayerStatus.STOPPED; + set((state) => { if (state.player.status === PlayerStatus.PLAYING) { state.player.status = PlayerStatus.PAUSED; @@ -1255,6 +1311,10 @@ export const usePlayerStoreBase = createWithEqualityFn()( state.player.status = PlayerStatus.PLAYING; } }); + + if (wasStopped) { + emitPlayerPlayEvent(undefined, set, get); + } }, moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => { const itemUniqueIds = items.map((item) => item._uniqueId); @@ -1640,6 +1700,7 @@ export const usePlayerStoreBase = createWithEqualityFn()( const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status']; // If we're not restoring the play queue, we don't need the index property + // (it is meaningless without the queue) if (!shouldRestorePlayQueue) { excludedPlayerKeys.push('index'); } @@ -2076,6 +2137,7 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => { const uniqueId = song._uniqueId; state.queue.songs[song._uniqueId] = { ...updatedSong, + _contextPlaylistId: song._contextPlaylistId, _uniqueId: uniqueId, }; } @@ -2083,6 +2145,10 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => { }); }; +export const useCurrentPlaylistContextId = () => { + return usePlayerStoreBase((state) => state.getCurrentSong()?._contextPlaylistId ?? null); +}; + export const usePlayerMuted = () => { return usePlayerStoreBase((state) => state.player.muted); }; diff --git a/src/renderer/store/scrobble-debug.store.ts b/src/renderer/store/scrobble-debug.store.ts index e06e743c2..af23daf78 100644 --- a/src/renderer/store/scrobble-debug.store.ts +++ b/src/renderer/store/scrobble-debug.store.ts @@ -1,7 +1,10 @@ +import { useEffect, useState } from 'react'; import { createWithEqualityFn } from 'zustand/traditional'; import { PlayerStatus } from '/@/shared/types/types'; +const SCROBBLE_DEBUG_POLL_INTERVAL_MS = 1000; + export type ScrobbleDebugSnapshot = { eligibilityMet: boolean; lastListenSampleTimeSec: null | number; @@ -38,6 +41,26 @@ export const useScrobbleDebugStore = createWithEqualityFn()( snapshot: initialSnapshot, })); +export const useScrobbleDebugSnapshot = () => { + const [snapshot, setLocalSnapshot] = useState(() => useScrobbleDebugStore.getState().snapshot); + + useEffect(() => { + const syncSnapshot = () => { + const nextSnapshot = useScrobbleDebugStore.getState().snapshot; + setLocalSnapshot((prevSnapshot) => + prevSnapshot !== nextSnapshot ? nextSnapshot : prevSnapshot, + ); + }; + + syncSnapshot(); + const interval = setInterval(syncSnapshot, SCROBBLE_DEBUG_POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, []); + + return snapshot; +}; + export const publishScrobbleDebug = (partial: Partial) => { useScrobbleDebugStore.setState((state) => ({ snapshot: { ...state.snapshot, ...partial }, diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 5f2981417..e425b4d45 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -76,6 +76,17 @@ const HomeItemSchema = z.enum([ 'recentlyReleased', ]); +const AlbumGroupItemSchema = z.enum([ + 'albumArtists', + 'duration', + 'genres', + 'releaseDate', + 'releaseYear', + 'releaseType', + 'size', + 'songCount', +]); + const PlayerItemSchema = z.enum([ 'bit_depth', 'bit_rate', @@ -271,6 +282,26 @@ const MpvSettingsSchema = z.object({ replayGainMode: z.enum(['album', 'no', 'track']), replayGainPreampDB: z.number().optional(), }); +const EqSettingsSchema = z.object({ + bands: z.array( + z.object({ + freq: z.number(), + gain: z.number(), + }), + ), + enabled: z.boolean(), + preamp: z.number(), +}); + +const CompressorSettingsSchema = z.object({ + attack: z.number(), + enabled: z.boolean(), + knee: z.number(), + makeup: z.number(), + ratio: z.number(), + release: z.number(), + threshold: z.number(), +}); const CssSettingsSchema = z.object({ content: z.string().transform((val) => sanitizeCss(`