Compare commits

...

21 Commits

Author SHA1 Message Date
oHaoDaio d23f7619ec chore: Support pnpm v11 2026-06-09 22:15:47 -07:00
Kendall Garner 44de6f2207 chore: upgrade depdencencies (#2133)
* upgrade depdencencies
2026-06-09 21:18:47 -07:00
BotBlake f6f25154a1 add "enhancement" to exempt-issue-labels (#2061) 2026-06-09 11:22:36 -07:00
Pedro Vieira 880516069d fix: preserve infinite list cache on component remount (fixes random sort reshuffling) (#2097)
* fix: preserve infinite list cache on component remount

When browsing with random sort, navigating to a detail view and coming
back would reshuffle the list. This happens because the list component
unmounts, losing its internal ref guard, and the reset effect re-fetches
all pages — returning a new random order from the server.
2026-06-09 11:21:02 -07:00
Hosted Weblate 95970183db Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: 為什麼不加空格 <c++23@users.noreply.hosted.weblate.org>
2026-06-09 07:01:35 +00:00
Hosted Weblate 3a2c952d2a Translated using Weblate
Currently translated at 75.7% (943 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-07 22:01:21 +02:00
jeffvli 46b94a83f1 fix reportPlayback event chain (#2131)
- properly send stopped event on song change
- properly send both starting and playing evento n song change instead of only starting
2026-06-06 18:41:59 -07:00
jeffvli 40a1d1438d re-add missing scrobble on playback start when using playback report (#2131) 2026-06-06 18:07:53 -07:00
Hosted Weblate 905088cae7 Translated using Weblate
Currently translated at 85.6% (1066 of 1245 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 64.2% (800 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 44.3% (552 of 1245 strings) (Norwegian Bokmål)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nb_NO/

Co-authored-by: Vladimir Levitskiy <lvdm87@gmail.com>
Co-authored-by: klodrik <klodrik@zoominn.no>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-06 12:01:23 +00:00
Hosted Weblate 705b375dab Translated using Weblate
Currently translated at 20.1% (251 of 1245 strings) (Korean)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/

Translated using Weblate

Currently translated at 82.5% (1028 of 1245 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 43.2% (538 of 1245 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 57.7% (719 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Benjamin <ben@iipython.dev>
Co-authored-by: Vladimir Levitskiy <lvdm87@gmail.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-05 11:01:20 +00:00
Mathieu Lemay 0b537b07ee Add zenburn theme (#2112) 2026-06-04 09:55:17 -07:00
Hosted Weblate dfa6198bdd Translated using Weblate
Currently translated at 20.0% (249 of 1245 strings) (Korean)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 26.9% (336 of 1245 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 98.3% (1224 of 1245 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 46.9% (584 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: Benjamin <ben@iipython.dev>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-04 10:01:27 +00:00
jeffvli b9312d86fd update to v1.13.0 2026-06-03 01:01:04 -07:00
jeffvli 30a1bda93d add new i18n locales 2026-06-03 00:28:12 -07:00
jeffvli 0e24eeeb1c only show queue save toast on explicit request (#2090) 2026-06-03 00:28:12 -07:00
Hosted Weblate 58d4dea09a Translated using Weblate
Currently translated at 22.2% (277 of 1245 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 21.9% (273 of 1244 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-03 09:09:27 +02:00
jeffvli c4da44a443 add playlist/artist image upload for jellyfin (#2105) 2026-06-03 00:07:31 -07:00
jeffvli be3f959354 fetch local lyrics first if preferred (#2100) 2026-06-02 23:38:31 -07:00
Overdrive deb69ef8ea Feature: Add sleep timer for end of current album (#2081)
* Add logic for stopping playback at end of current album, unless in shuffle mode.
2026-06-02 23:22:56 -07:00
fiso64 5ac0aaeec0 fix(player): step correctly when seeking rapidly (#2084)
* fix(player): step correctly when seeking rapidly

mediaSkipBackward/mediaSkipForward compute the new target relative to
the timestamp store, which is only refreshed by a ~500ms engine poll.
Rapid presses within that window all read the same stale position and
recompute the same target, so the time appears stuck until the poll
catches up.

Update the timestamp store immediately when a seek is issued so
subsequent presses compute from the new position; the poll then just
reconciles to the same value. Also applied to mediaSeekToTimestamp so
absolute seeks reflect in the UI without the poll lag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(player): unify seek comments per review

Use one canonical explanation in mediaSkipBackward and have
mediaSeekToTimestamp/mediaSkipForward reference it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:11:21 +00:00
Kendall Garner 515cadb916 Better cross platform font handling (#2104)
* fix: better handling of custom font

Practically speaking, custom font seems to have only worked on Linux, because
`net.fetch` would include the mime type in the response headers which could validate the payload.
This doesn't appear to be the case on windows/macOS. Instead:

1. On Linux (or if some other system supports it), check the content type. If good, serve as normal
2. Otherwise, fetch the payload. Read the first four to five bytes and check for a valid magic number.

Additionally, to prevent arbitrary requests fetching other paths via injected content, sync the custom font path
to the main process, and then make _every_ request to `feishin:/` point to the same renderer path.
When setting the font, first send the path to the main process. This will register `feishin:/` to point
to the path provided. This is done via a promise-based set.

Finally, provide a default value for the file input (a best effort approximation for the last part of the file path)
on the file input component.

* make the linter happy
2026-06-02 19:34:16 +00:00
59 changed files with 4618 additions and 2692 deletions
-4
View File
@@ -44,8 +44,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
@@ -129,8 +127,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-4
View File
@@ -19,8 +19,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
@@ -123,8 +121,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -38,8 +38,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
+1 -1
View File
@@ -43,6 +43,6 @@ jobs:
stale-issue-label: 'stale'
exempt-issue-labels: 'keep,security'
exempt-issue-labels: 'keep,security,enhancement'
stale-pr-label: 'stale'
exempt-pr-labels: 'keep,security'
-2
View File
@@ -12,8 +12,6 @@ jobs:
- name: Install Node.js and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
+1 -4
View File
@@ -3,10 +3,7 @@ FROM node:23-alpine AS builder
WORKDIR /app
# Copy package.json first to cache node_modules
COPY package.json pnpm-lock.yaml .
# Match CI (pnpm/action-setup version: 10). Latest pnpm 11 fails install without approve-builds.
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
RUN pnpm install
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
publish:
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+43 -43
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.12.1",
"version": "1.13.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -69,79 +69,78 @@
"postversion": "node ./scripts/update-app-stream.mjs"
},
"resolutions": {
"react-router": "7.14.0",
"xml2js": "0.5.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^9.1.1",
"@mantine/core": "^9.1.1",
"@mantine/dates": "^9.1.1",
"@mantine/form": "^9.1.1",
"@mantine/hooks": "^9.1.1",
"@mantine/modals": "^9.1.1",
"@mantine/notifications": "^9.1.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2",
"@mantine/colors-generator": "^9.3.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/form": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@mantine/modals": "^9.3.0",
"@mantine/notifications": "^9.3.0",
"@radix-ui/react-context-menu": "^2.3.0",
"@tanstack/react-query": "5.96.2",
"@tanstack/react-query-devtools": "5.96.2",
"@tanstack/react-query-persist-client": "5.96.2",
"@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.12",
"@xhayper/discord-rpc": "^1.3.3",
"@xhayper/discord-rpc": "^1.3.4",
"audiomotion-analyzer": "^4.5.4",
"axios": "^1.14.0",
"axios": "^1.17.0",
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"cheerio": "^1.2.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",
"electron-log": "^5.4.4",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"fast-average-color": "9.5.0",
"fast-xml-parser": "^5.5.10",
"fast-xml-parser": "^5.8.0",
"format-duration": "^3.0.2",
"fuse.js": "^7.2.0",
"fuse.js": "^7.4.2",
"i18next": "^25.10.10",
"icecast-metadata-stats": "^0.1.12",
"idb-keyval": "^6.2.2",
"idb-keyval": "^6.2.5",
"immer": "^10.2.0",
"is-electron": "^2.2.2",
"lodash": "^4.18.1",
"md5": "^2.3.0",
"motion": "^12.38.0",
"motion": "^12.40.0",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"qs": "^6.15.0",
"react": "^19.2.4",
"qs": "^6.15.2",
"react": "^19.2.7",
"react-call": "^1.8.2",
"react-dom": "^19.2.4",
"react-dom": "^19.2.7",
"react-error-boundary": "^5.0.0",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-player": "^2.16.1",
"react-router": "^7.14.0",
"react-router": "^7.17.0",
"react-split-pane": "^3.2.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.7.4",
"semver": "^7.8.2",
"string-to-color": "^2.2.2",
"wavesurfer.js": "^7.12.5",
"ws": "^8.20.0",
"wavesurfer.js": "^7.12.7",
"ws": "^8.21.0",
"zod": "^3.25.76",
"zustand": "^5.0.12"
"zustand": "^5.0.14"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@@ -150,8 +149,8 @@
"@types/electron-localshortcut": "^3.1.3",
"@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/node": "^24.13.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/source-map-support": "^0.5.10",
@@ -160,32 +159,33 @@
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^41.7.0",
"electron-builder": "^26.8.2",
"electron": "^41.7.1",
"electron-builder": "^26.15.0",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.26",
"i18next-parser": "^9.4.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"prettier": "^3.8.3",
"prettier-plugin-packagejson": "^2.5.22",
"stylelint": "^16.26.1",
"stylelint-config-css-modules": "^4.6.0",
"stylelint-config-recess-order": "^7.7.0",
"stylelint-config-standard": "^39.0.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^7.3.5",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.2.0"
"vite-plugin-pwa": "^1.3.0"
},
"packageManager": "pnpm@11.5.2",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
+2186 -2388
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
allowBuilds:
abstract-socket: true
electron: true
electron-winstaller: true
esbuild: true
overrides:
"xml2js": "0.5.0"
"react-router": "7.14.0"
+18
View File
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
import de from './locales/de.json';
import en from './locales/en.json';
import es from './locales/es.json';
import et from './locales/et.json';
import eu from './locales/eu.json';
import fa from './locales/fa.json';
import fi from './locales/fi.json';
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import ta from './locales/ta.json';
import th from './locales/th.json';
import tl from './locales/tl.json';
import tr from './locales/tr.json';
import zhHans from './locales/zh-Hans.json';
import zhHant from './locales/zh-Hant.json';
@@ -38,6 +41,7 @@ const resources = {
de: { translation: de },
en: { translation: en },
es: { translation: es },
et: { translation: et },
eu: { translation: eu },
fa: { translation: fa },
fi: { translation: fi },
@@ -57,6 +61,8 @@ const resources = {
sr: { translation: sr },
sv: { translation: sv },
ta: { translation: ta },
th: { translation: th },
tl: { translation: tl },
tr: { translation: tr },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
@@ -87,6 +93,10 @@ export const languages = [
label: 'Español',
value: 'es',
},
{
label: 'Eesti',
value: 'et',
},
{
label: 'Basque',
value: 'eu',
@@ -163,6 +173,14 @@ export const languages = [
label: 'Tamil',
value: 'ta',
},
{
label: 'Thai',
value: 'th',
},
{
label: 'Tagalog',
value: 'tl',
},
{
label: 'Türkçe',
value: 'tr',
+2 -1
View File
@@ -1182,7 +1182,8 @@
"sleepTimer_setCustom": "Configura el temporitzador",
"sleepTimer_cancel": "Cancel·la el temporitzador",
"albumRadio": "Ràdio d'àlbums",
"scrobbleForceSubmit": "Força l'scrobble"
"scrobbleForceSubmit": "Força l'scrobble",
"sleepTimer_endOfAlbum": "Final de l'àlbum actual"
},
"error": {
"credentialsRequired": "Credencials requerides",
+5 -4
View File
@@ -49,7 +49,8 @@
"sleepTimer_setCustom": "Nastavit časovač",
"sleepTimer_cancel": "Zrušit časovač",
"albumRadio": "Rádio alba",
"scrobbleForceSubmit": "Vynutit scrobble"
"scrobbleForceSubmit": "Vynutit scrobble",
"sleepTimer_endOfAlbum": "Konec aktuálního alba"
},
"setting": {
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
@@ -1169,7 +1170,7 @@
"playlist_few": "playlisty",
"playlist_other": "Playlisty",
"artist_one": "Umělec",
"artist_few": "umělci",
"artist_few": "Umělci",
"artist_other": "Umělci",
"folderWithCount_one": "{{count}} složka",
"folderWithCount_few": "{{count}} složky",
@@ -1187,7 +1188,7 @@
"albumWithCount_few": "{{count}} alba",
"albumWithCount_other": "{{count}} alb",
"favorite_one": "Oblíbený",
"favorite_few": "oblíbené",
"favorite_few": "Oblíbené",
"favorite_other": "Oblíbené",
"artistWithCount_one": "{{count}} umělec",
"artistWithCount_few": "{{count}} umělci",
@@ -1197,7 +1198,7 @@
"folder_other": "Složky",
"smartPlaylist": "Chytrý $t(entity.playlist, {\"count\": 1})",
"album_one": "Album",
"album_few": "alba",
"album_few": "Alba",
"album_other": "Alba",
"genreWithCount_one": "{{count}} žánr",
"genreWithCount_few": "{{count}} žánry",
+1
View File
@@ -699,6 +699,7 @@
"viewQueue": "View queue",
"sleepTimer": "Sleep timer",
"sleepTimer_endOfSong": "End of current song",
"sleepTimer_endOfAlbum": "End of current album",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "Custom",
+2 -1
View File
@@ -49,7 +49,8 @@
"sleepTimer_endOfSong": "Fin de la canción actual",
"sleepTimer": "Temporizador de apagado",
"albumRadio": "Radio del álbum",
"scrobbleForceSubmit": "Forzar scrobble"
"scrobbleForceSubmit": "Forzar scrobble",
"sleepTimer_endOfAlbum": "Fin del álbum actual"
},
"setting": {
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
+1122 -1
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -49,7 +49,8 @@
"sleepTimer_setCustom": "Définir le minuteur",
"sleepTimer_cancel": "Annuler le minuteur",
"albumRadio": "Radio d'album",
"scrobbleForceSubmit": "Forcer le scrobble"
"scrobbleForceSubmit": "Forcer le scrobble",
"sleepTimer_endOfAlbum": "Fin de l'album actuel"
},
"action": {
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
@@ -226,7 +227,8 @@
"rename": "Renommer",
"newVersionAvailable": "Une nouvelle version est disponible",
"numberOfResults": "{{numberOfResults}} résultats",
"back": "Retour"
"back": "Retour",
"openFolder": "Ouvrir le dossier"
},
"error": {
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
@@ -728,7 +730,7 @@
"translationTargetLanguage": "Langue cible de traduction",
"trayEnabled": "Afficher la barre d’état système",
"translationApiProvider_description": "Fournisseur d'API pour la traduction",
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement",
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement. Application de Bureau uniquement: feishin lit et écrit le fichier custom.css dans le répertoire de configuration de l'application et le recharge lorsque celui-ci est modifié",
"translationApiKey": "Clé API de traduction",
"translationTargetLanguage_description": "Langue cible pour la traduction",
"trayEnabled_description": "Afficher/masquer licône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système",
@@ -813,7 +815,7 @@
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
"autoDJ": "DJ auto",
"autoDJ_itemCount": "Nombre d'entrée",
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente",
"autoDJ_timing": "Timing",
"autoDJ_timing_description": "Le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
"followCurrentSong_description": "Défiler automatiquement la file d'attente jusqu'au titre en cours",
@@ -915,7 +917,16 @@
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
"sidebarPlaylistMode_optionCompact": "Compacte"
"sidebarPlaylistMode_optionCompact": "Compacte",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Albums",
"autoDJ_mode_description": "Choisissez d'ajouter des titres ou des albums entiers à la file d'attente",
"autoDJ_mode_songs": "Titres",
"autoDJ_enabled": "Activer le DJ auto",
"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"
},
"form": {
"deletePlaylist": {
@@ -1009,7 +1020,12 @@
"input_played": "Filtre de lecture",
"input_played_optionAll": "Toutes les pistes",
"input_played_optionUnplayed": "Seulement les pistes non jouées",
"input_played_optionPlayed": "Seulement les pistes jouées"
"input_played_optionPlayed": "Seulement les pistes jouées",
"input_kind_songs": "Titres",
"input_kind_albums": "Albums",
"input_kind": "Sélections aléatoires",
"input_limit_albums": "Combien d'albums?",
"input_limit_songs": "Combien de titres?"
},
"createRadioStation": {
"success": "Station radio créée avec succès",
+39 -23
View File
@@ -49,7 +49,8 @@
"albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する",
"scrobbleForceSubmit": "強制 Scrobble"
"scrobbleForceSubmit": "強制 Scrobble",
"sleepTimer_endOfAlbum": "現在のアルバムの終了"
},
"setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -430,12 +431,21 @@
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
"preventSuspendOnPlayback": "再生の中断を防止する",
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
"autoDJ_mode": "モード",
"autoDJ_mode_albums": "アルバム",
"autoDJ_mode_description": "キューに曲を追加するか、アルバム全体を追加するかを選択してください。",
"autoDJ_mode_songs": "曲",
"autoDJ_enabled": "Auto DJを有効にする",
"autoDJ_albumStrategy": "アルバム選択モード",
"autoDJ_songStrategy": "選曲モード",
"autoDJ_strategy_option_library_random": "ランダム",
"autoDJ_strategy_option_similar": "類似"
},
"action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
"goToPage": "ページへ移動",
"moveToTop": "先頭に移動",
"moveToTop": "一番上へ移動",
"clearQueue": "キューをクリア",
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) に追加",
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) に追加",
@@ -446,9 +456,9 @@
"deletePlaylist": "$t(entity.playlist, {\"count\": 1}) を削除",
"removeFromQueue": "キューから削除",
"deselectAll": "すべて選択解除",
"moveToBottom": "末尾に移動",
"setRating": "評価を設定する",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディタ切り替え",
"moveToBottom": "一番下へ移動",
"setRating": "評価を設定",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディターを切り替え",
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
"openIn": {
"lastfm": "Last.fm で開く",
@@ -457,9 +467,9 @@
"listenbrainz": "ListenBrainz で開く",
"qobuz": "Qobuz で開く"
},
"moveToNext": "次",
"moveToNext": "次へ進む",
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
"moveItems": "を移動",
"moveItems": "項目を移動",
"shuffle": "シャッフル",
"shuffleAll": "すべてシャッフル",
"shuffleSelected": "選択した曲をシャッフル",
@@ -471,28 +481,28 @@
"moveDown": "下に移動",
"holdToMoveToTop": "押し続けると一番上に移動します",
"holdToMoveToBottom": "押し続けると一番下に移動します",
"openApplicationDirectory": "アプリケーションディレクトリを開く",
"openApplicationDirectory": "アプリディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除",
"addOrRemoveFromSelection": "選択に追加または選択から除外",
"goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
},
"common": {
"backward": "戻る",
"backward": "逆行",
"increase": "増加",
"rating": "評価",
"bpm": "BPM",
"refresh": "再読み込み",
"unknown": "不明",
"areYouSure": "実行しすか?",
"areYouSure": "実行してもよろしいですか",
"edit": "編集",
"favorite": "お気に入り",
"left": "左側",
"save": "保存",
"right": "右側",
"currentSong": "現在の $t(entity.track, {\"count\": 1})",
"collapse": "折りたた",
"collapse": "折りたた",
"trackNumber": "トラック",
"descending": "降順",
"add": "追加",
@@ -534,7 +544,7 @@
"confirm": "確認",
"resetToDefault": "デフォルトにリセット",
"home": "ホーム",
"comingSoon": "近日利用可能になる予定です…",
"comingSoon": "近日公開…",
"reset": "リセット",
"channel_other": "チャンネル",
"disable": "無効",
@@ -543,7 +553,7 @@
"menu": "メニュー",
"restartRequired": "再起動が必要です",
"previousSong": "前の $t(entity.track, {\"count\": 1})",
"noResultsFromQuery": "条件にマッチするものがありません",
"noResultsFromQuery": "クエリに一致する結果がありません",
"quit": "終了",
"expand": "展開",
"search": "検索",
@@ -553,11 +563,11 @@
"random": "ランダム",
"size": "サイズ",
"biography": "バイオグラフィー",
"note": "ノート",
"note": "注記",
"explicitStatus": "明示的なステータス",
"additionalParticipants": "追加参加者",
"newVersion": "新しいバージョン ({{version}}) がインストールされました",
"viewReleaseNotes": "リリースノートを表示する",
"viewReleaseNotes": "リリースノートを表示",
"bitDepth": "ビット深度",
"close": "閉じる",
"codec": "コーデック",
@@ -565,7 +575,7 @@
"sampleRate": "サンプルレート",
"preview": "プレビュー",
"private": "プライベート",
"public": "パブリック",
"public": "公開",
"share": "共有",
"tags": "タグ",
"trackGain": "トラックゲイン",
@@ -598,7 +608,8 @@
"newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化",
"back": "戻る"
"back": "戻る",
"openFolder": "フォルダーを開く"
},
"table": {
"config": {
@@ -710,7 +721,7 @@
}
},
"error": {
"remotePortWarning": "新たなポート設定を適用するためサーバーを再起動してください",
"remotePortWarning": "新しいポート設定を反映させるには、サーバーを再起動してください",
"systemFontError": "システムフォントを取得する際にエラーが発生しました",
"playbackError": "メディアの再生開始時にエラーが発生しました",
"remotePortError": "リモートサーバーのポート設定時にエラーが発生しました",
@@ -725,7 +736,7 @@
"serverNotSelectedError": "サーバーが選択されていません",
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
"mpvRequired": "MPV が必要です",
"audioDeviceFetchError": "オーディオデバイス取得にエラーが発生しました",
"audioDeviceFetchError": "オーディオデバイス取得しようとした際にエラーが発生しました",
"invalidServer": "無効なサーバー",
"loginRateError": "ログイン試行回数が多すぎます。数秒後に再試行してください",
"endpointNotImplementedError": "{{serverType}} にはエンドポイント {{endpoint}} が実装されていません",
@@ -733,7 +744,7 @@
"networkError": "ネットワークエラーが発生しました",
"notificationDenied": "通知の許可が拒否されました。この設定は効果がありません",
"openError": "ファイルを開けませんでした",
"badValue": "無効なオプション「{{value}}」。この値は存在しません",
"badValue": "無効なオプション「{{value}}」です。この値は存在しません",
"multipleServerSaveQueueError": "再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません",
"noNetwork": "サーバーが利用できません",
"noNetworkDescription": "このサーバーに接続できませんでした",
@@ -1109,7 +1120,12 @@
"input_played_optionAll": "すべてのトラック",
"input_played_optionUnplayed": "未再生のトラックのみ",
"input_played_optionPlayed": "再生されたトラックのみ",
"input_played": "再生フィルター"
"input_played": "再生フィルター",
"input_kind_albums": "アルバム",
"input_kind_songs": "曲",
"input_kind": "ランダムピック",
"input_limit_albums": "アルバムは何枚ですか?",
"input_limit_songs": "何曲ですか?"
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
+244 -24
View File
@@ -17,7 +17,10 @@
"removeFromPlaylist": "$t(entity.playlist, {\"count\": 1})에서 제거",
"openIn": {
"musicbrainz": "MusicBrainz에서 보기",
"lastfm": "Last.fm에서 보기"
"lastfm": "Last.fm에서 보기",
"listenbrainz": "ListenBrainz에서 열기",
"qobuz": "Qobuz에서 열기",
"spotify": "Spotify에서 열기"
},
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) 보기",
"setRating": "평점 지정",
@@ -37,7 +40,10 @@
"shuffleAll": "모두 섞기",
"shuffleSelected": "선택항목 섞기",
"viewMore": "더 보기",
"openApplicationDirectory": "앱 디렉토리 열기"
"openApplicationDirectory": "앱 디렉토리 열기",
"goToCurrent": "현재 항목으로 이동",
"collapseAllFolders": "모든 폴더 접기",
"expandAllFolders": "모든 폴더 확장"
},
"common": {
"translation": "번역",
@@ -149,7 +155,18 @@
"sort": "정렬",
"gridRows": "행 그리드",
"tableColumns": "테이블 열",
"itemsMore": "{{count}}개 더"
"itemsMore": "{{count}}개 더",
"back": "뒤로",
"example": "예",
"openFolder": "폴더 열기",
"filter_single": "미혼",
"filter_multiple": "다중",
"grouping": "그룹화",
"mood": "기분",
"numberOfResults": "결과 {{numberOfResults}}개",
"retry": "다시 해 보다",
"rename": "이름 변경",
"newVersionAvailable": "새로운 버전이 나왔습니다"
},
"entity": {
"albumWithCount_other": "{{count}} 앨범",
@@ -197,7 +214,15 @@
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다",
"apiRouteError": "요청 보내기 실패",
"badValue": "옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다",
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다"
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다",
"invalidJson": "유효하지 않은 JSON",
"multipleServerSaveQueueError": "재생 대기열에 현재 서버에 속하지 않은 곡이 하나 이상 포함되어 있습니다. 이는 지원되지 않습니다",
"noNetwork": "서버를 이용할 수 없음",
"noNetworkDescription": "이 서버에 연결할 수 없습니다",
"playbackPausedDueToError": "오류로 인해 재생이 일시 중지되었습니다",
"saveQueueFailed": "큐 저장 실패",
"serverLockSingleServer": "서버가 잠겨 있을 때는 서버를 하나만 허용합니다",
"settingsSyncError": "렌더러와 메인 프로세스의 설정 간에 불일치가 발견되었습니다. 변경 사항을 적용하려면 애플리케이션을 다시 시작하십시오"
},
"filter": {
"title": "곡명",
@@ -222,7 +247,7 @@
"disc": "디스크",
"bitrate": "비트 전송률",
"biography": "바이오그래피",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"duration": "길이",
"bpm": "BPM",
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
@@ -242,7 +267,10 @@
"songCount": "곡 갯수",
"toYear": "년도까지",
"trackNumber": "트랙",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "그리고",
"matchOr": "또는",
"sortName": "이름 정렬"
},
"form": {
"addServer": {
@@ -258,7 +286,10 @@
"input_legacyAuthentication": "레거시 인증 사용",
"input_username": "유저 이름",
"input_preferInstantMix": "즉석 믹스 선호",
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다"
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다",
"input_preferRemoteUrl": "공개 URL 선호",
"input_remoteUrl": "공개 URL",
"input_remoteUrlPlaceholder": "선택 사항: 외부 기능을 위한 공개 URL"
},
"addToPlaylist": {
"input_skipDuplicates": "중복 건너뛰기",
@@ -266,7 +297,8 @@
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\"count\": {{message}} })가 추가되었습니다",
"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}}'에 트랙이 추가되지 않았습니다"
},
"lyricSearch": {
"title": "가사 검색",
@@ -276,7 +308,11 @@
"queryEditor": {
"input_optionMatchAll": "모두 일치",
"input_optionMatchAny": "무엇이든 일치",
"title": "쿼리 편집기"
"title": "쿼리 편집기",
"addRuleGroup": "규칙 그룹 추가",
"removeRuleGroup": "규칙 그룹 제거",
"resetToDefault": "기본값으로 초기화",
"clearFilters": "필터 초기화"
},
"editPlaylist": {
"title": "$t(entity.playlist, {\"count\": 1}) 편집",
@@ -289,7 +325,9 @@
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
"setExpiration": "만료 기간 설정하기"
"setExpiration": "만료 기간 설정하기",
"copyToClipboard": "클립보드로 복사: Ctrl+C, Enter",
"successMustClick": "공유가 성공적으로 생성되었습니다. 여기를 클릭하여 여세요"
},
"updateServer": {
"title": "서버 업데이트",
@@ -312,6 +350,44 @@
"enabled": "프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다",
"disabled": "프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다",
"title": "프라이빗 모드"
},
"largeFetchConfirmation": {
"title": "대기열에 항목을 추가하세요",
"description": "이 작업은 현재 필터링된 보기의 모든 항목을 추가합니다"
},
"createRadioStation": {
"success": "라디오 방송국이 성공적으로 생성되었습니다",
"title": "라디오 방송국 만들기",
"input_homepageUrl": "홈페이지 URL",
"input_name": "명의",
"input_streamUrl": "스트림 URL"
},
"editRadioStation": {
"success": "라디오 방송국이 성공적으로 업데이트되었습니다"
},
"lyricsExport": {
"export": "가사 내보내기",
"input_synced": "동기화된 가사 내보내기",
"input_offset": "$t(setting.lyricOffset)"
},
"saveQueue": {
"success": "재생 대기열을 서버에 저장했습니다"
},
"shuffleAll": {
"title": "무작위 재생",
"input_kind_albums": "앨범",
"input_kind_songs": "노래들",
"input_kind": "무작위 선택",
"input_limit_albums": "앨범이 몇 장인가요?",
"input_limit_songs": "몇 곡인가요?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "몇 곡인가요?",
"input_minYear": "연도부터",
"input_maxYear": "연도까지",
"input_played": "재생 필터",
"input_played_optionAll": "모든 트랙",
"input_played_optionUnplayed": "재생하지 않은 트랙만",
"input_played_optionPlayed": "재생된 트랙만"
}
},
"page": {
@@ -325,7 +401,13 @@
"collapseSidebar": "사이드바 줄이기",
"expandSidebar": "사이드바 확장",
"privateModeOff": "프라이빗 모드 끄기",
"privateModeOn": "프라이빗 모드 켜기"
"privateModeOn": "프라이빗 모드 켜기",
"commandPalette": "명령 팔레트 열기",
"quit": "$t(common.quit)",
"selectMusicFolder": "음악 폴더 선택",
"noMusicFolder": "음악 폴더가 선택되지 않았습니다",
"multipleMusicFolders": "{{count}}개의 음악 폴더가 선택되었습니다",
"settings": "$t(common.setting, {\"count\": 2})"
},
"manageServers": {
"title": "서버 설정하기",
@@ -350,7 +432,9 @@
"lyricGap": "가사 간격",
"lyricSize": "가사 크기",
"showLyricMatch": "가사 일치 표시",
"showLyricProvider": "가사 제공자 표시"
"showLyricProvider": "가사 제공자 표시",
"lyricOpacityNonActive": "비활성 가사 불투명도",
"lyricScaleNonActive": "비활성 서정적 척도"
},
"lyrics": "가사",
"related": "관련",
@@ -364,7 +448,27 @@
"shareItem": "공유",
"goToAlbum": "$t(entity.album, {\"count\": 1})으로 이동",
"goToAlbumArtist": "$t(entity.albumArtist, {\"count\": 1})으로 이동",
"showDetails": "추가정보"
"showDetails": "추가정보",
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"goTo": "이동"
},
"albumArtistDetail": {
"about": "{{artist}}에 대해",
@@ -375,7 +479,13 @@
"topSongs": "최고의 곡들",
"topSongsFrom": "{{title}}이 포함된 최고의 곡들",
"viewAll": "전부 보이기",
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기"
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기",
"favoriteSongs": "좋아하는 노래들",
"groupingTypeAll": "모든 릴리스 유형",
"groupingTypePrimary": "주요 릴리스 유형",
"topSongsCommunity": "공동체",
"topSongsPersonal": "개인의",
"favoriteSongsFrom": "{{title}}에서 가장 좋아하는 곡들"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -386,11 +496,14 @@
"released": "발매"
},
"albumList": {
"artistAlbums": "{{artist}}의 앨범"
"artistAlbums": "{{artist}}의 앨범",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"genreList": {
"showAlbums": "$t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2}) 표시",
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시"
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시",
"title": "$t(entity.genre, {\"count\": 2})"
},
"globalSearch": {
"commands": {
@@ -405,7 +518,9 @@
"mostPlayed": "자주 플레이된 곡",
"newlyAdded": "최근에 추가된 곡",
"recentlyPlayed": "최근에 플레이된 곡",
"recentlyReleased": "최근에 발매된 곡"
"recentlyReleased": "최근에 발매된 곡",
"genres": "$t(entity.genre, {\"count\": 2})",
"title": "$t(common.home)"
},
"itemDetail": {
"copyPath": "클립보드에 경로를 복사",
@@ -420,15 +535,71 @@
"generalTab": "일반",
"hotkeysTab": "단축키",
"playbackTab": "재생",
"windowTab": "윈도우"
"windowTab": "윈도우",
"analytics": "해석학",
"updates": "업데이트",
"cache": "은닉처",
"application": "애플리케이션",
"queryBuilder": "쿼리 빌더",
"theme": "테마",
"controls": "통제 수단",
"sidebar": "사이드바",
"exportImport": "가져오기/내보내기",
"audio": "오디오",
"lyrics": "가사",
"lyricsDisplay": "가사 표시",
"transcoding": "트랜스코딩",
"discord": "Discord",
"logger": "로거",
"playerFilters": "선수 필터"
},
"sidebar": {
"myLibrary": "내 라이브러리",
"nowPlaying": "재생중",
"shared": "공유 $t(entity.playlist, {\"count\": 2})"
"shared": "공유 $t(entity.playlist, {\"count\": 2})",
"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})",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})"
},
"trackList": {
"artistTracks": "{{artist}}의 음악"
"artistTracks": "{{artist}}의 음악",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"radioList": {
"title": "라디오 방송국"
},
"releasenotes": {
"commitsSinceStable": "{{stable}} 이후 커밋",
"noNewCommits": "이 범위에 새로운 커밋이 없습니다",
"noStableReleaseToCompare": "비교할 수 있는 안정화 릴리스가 없습니다"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"windowBar": {
"paused": "(일시 정지됨) ",
"privateMode": "(비공개 모드)"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "기존 항목 덮어쓰기",
"saveAsCollection": "컬렉션으로 저장"
}
},
"table": {
@@ -473,7 +644,25 @@
"toggleFullscreenPlayer": "전체화면으로 전환",
"unfavorite": "즐겨찾기 취소",
"pause": "멈춤",
"viewQueue": "대기열 보기"
"viewQueue": "대기열 보기",
"addLastShuffled": "마지막 (섞인)",
"addNextShuffled": "다음 (무작위)",
"albumRadio": "앨범 라디오",
"artistRadio": "아티스트 라디오",
"holdToShuffle": "길게 눌러 섞기",
"lyrics": "가사",
"restoreQueueFromServer": "서버에서 큐 복원",
"saveQueueToServer": "대기열을 서버에 저장",
"trackRadio": "라디오 추적",
"sleepTimer": "취침 타이머",
"sleepTimer_endOfSong": "현재 곡 종료",
"sleepTimer_endOfAlbum": "현재 앨범의 끝",
"sleepTimer_minutes": "{{count}}분",
"sleepTimer_hours": "{{count}}시간",
"sleepTimer_off": "끄다",
"sleepTimer_timeRemaining": "{{time}} 남음",
"sleepTimer_setCustom": "타이머 설정",
"sleepTimer_cancel": "타이머 취소"
},
"setting": {
"accentColor_description": "앱의 강조색상 설정",
@@ -482,7 +671,7 @@
"albumBackground": "앨범 배경이미지",
"albumBackgroundBlur_description": "앨범 배경이미지의 흐려짐 정도 조정",
"albumBackgroundBlur": "앨범배경이미지 흐려짐 크기",
"applicationHotkeys_description": "앱의 단축키 설정. 앱 전체에 적용되는 단축키 설정하기 위해서는 체크박스에 체크하세요(PC만 가능)",
"applicationHotkeys_description": "애플리케이션 단축키 설정합니다. 체크박스를 전환하여 전역 단축키 설정하세요(데스크톱 전용)",
"applicationHotkeys": "앱 단축키",
"artistBackground": "아티스트 배경이미지",
"artistBackground_description": "아티스트 페이지에 아티스트가 포함된 배경이미지를 추가",
@@ -492,7 +681,7 @@
"artistConfiguration_description": "앨범아티스트 페이지에 표시할 정보 및 순서 설정",
"audioDevice_description": "음악재생에 사용할 장치 선택(웹플레이어만 가능)",
"audioDevice": "오디오 장치",
"audioExclusiveMode_description": "단독재생모드 켜기. 이 모드에서는 일반적으로 시스템의 재생장치가 고정되며 MPV로만 오디오가 재생됩니다",
"audioExclusiveMode_description": "독점 출력 모드를 활성화합니다. 이 모드에서는 일반적으로 시스템의 오디오 출력이 차단되며, 오직 mpv만이 오디오를 출력할 수 있습니다. 이 모드가 활성화된 동안에는 비주얼라이저의 시스템 오디오 캡처 기능이 작동하지 않습니다",
"audioExclusiveMode": "오디오 단독재생모드",
"audioPlayer_description": "재생을 위한 오디오 플레이어 선택",
"audioPlayer": "오디오 플레이어",
@@ -505,7 +694,8 @@
"broadcast": "방송",
"ep": "ep앨범",
"other": "기타",
"single": "싱글"
"single": "싱글",
"album": "$t(entity.album, {\"count\": 1})"
},
"secondary": {
"audiobook": "오디오북",
@@ -521,5 +711,35 @@
"soundtrack": "사운드트랙",
"spokenWord": "보컬사운드"
}
},
"datetime": {
"minuteShort": "분",
"secondShort": "초",
"hourShort": "시간",
"dayShort": "일"
},
"filterOperator": {
"after": "~ 뒤에 있나요",
"afterDate": "(날짜) 이후입니까",
"before": "~보다 앞서 있다",
"beforeDate": "(날짜) 이전인가요",
"contains": "포함",
"endsWith": "~로 끝남",
"inPlaylist": "~ 안에 있다",
"inTheLast": "마지막에 있습니다",
"inTheRange": "범위 내에 있습니다",
"inTheRangeDate": "범위 내에 있음 (날짜)",
"is": "~이다",
"isNot": "~이 아닙니까",
"isGreaterThan": "~보다 크다",
"isLessThan": "~보다 작다",
"matchesRegex": "정규식과 일치",
"notContains": "함유하지 않음",
"notInPlaylist": "~ 안에 있지 않다",
"notInTheLast": "마지막에 있지 않다",
"startsWith": "~로 시작함"
},
"queryBuilder": {
"customTags": "사용자 정의 태그"
}
}
+25 -4
View File
@@ -3,7 +3,9 @@
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz",
"spotify": "Åpne i Spotify"
"spotify": "Åpne i Spotify",
"listenbrainz": "Åpne i ListenBrainz",
"qobuz": "Åpne i Qobuz"
},
"moveToBottom": "Flytt til bunnen",
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
@@ -38,7 +40,10 @@
"shuffleAll": "Tilfelding avspilling av alt",
"shuffleSelected": "Tilfelding avspilling av utvalgte",
"viewMore": "Se mer",
"openApplicationDirectory": "Åpne applikasjonskatalogen"
"openApplicationDirectory": "Åpne applikasjonskatalogen",
"goToCurrent": "Gå til gjeldende element",
"collapseAllFolders": "Skjul alle mapper",
"expandAllFolders": "Utvid alle mapper"
},
"common": {
"bpm": "Bpm",
@@ -161,7 +166,11 @@
"tableColumns": "Tabellkolonner",
"itemsMore": "{{count}} fler",
"explicitStatus": "Grovhetsstatus",
"newVersionAvailable": "En ny version er tilgjengelig"
"newVersionAvailable": "En ny version er tilgjengelig",
"back": "Tilbake",
"openFolder": "Åpne mappe",
"grouping": "Grupper",
"numberOfResults": "{{numberOfResults}} resultater"
},
"entity": {
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
@@ -667,7 +676,19 @@
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
"z": "Z",
"none": "Ingen"
},
"frequencyScale": {
"linear": "Lineær skala",
"log": "Logaritmisk skala"
},
"channelLayout": {
"single": "Enkel"
},
"gradient": {
"rainbow": "Regnbue",
"prism": "Prisme"
}
}
}
+14 -13
View File
@@ -178,14 +178,14 @@
},
"entity": {
"genre_one": "Gatunek",
"genre_few": "gatunki",
"genre_many": "gatunków",
"genre_few": "Gatunki",
"genre_many": "Gatunków",
"playlistWithCount_one": "{{count}} playlista",
"playlistWithCount_few": "{{count}} playlisty",
"playlistWithCount_many": "{{count}} playlist",
"playlist_one": "Playlista",
"playlist_few": "playlisty",
"playlist_many": "playlist",
"playlist_few": "Playlisty",
"playlist_many": "Playlist",
"artist_one": "Wykonawca",
"artist_few": "wykonawcy",
"artist_many": "wykonawców",
@@ -196,8 +196,8 @@
"albumArtist_few": "Wykonawców albumów",
"albumArtist_many": "Wykonawców albumów",
"track_one": "Utwór",
"track_few": "utwory",
"track_many": "utworów",
"track_few": "Utwory",
"track_many": "Utworów",
"albumArtistCount_one": "{{count}} wykonawca albumu",
"albumArtistCount_few": "{{count}} wykonawców albumu",
"albumArtistCount_many": "{{count}} wykonawców albumu",
@@ -205,18 +205,18 @@
"albumWithCount_few": "{{count}} albumy",
"albumWithCount_many": "{{count}} albumów",
"favorite_one": "Ulubiony",
"favorite_few": "ulubione",
"favorite_many": "ulubionych",
"favorite_few": "Ulubione",
"favorite_many": "Ulubionych",
"artistWithCount_one": "{{count}} wykonawca",
"artistWithCount_few": "{{count}} wykonawców",
"artistWithCount_many": "{{count}} wykonawców",
"folder_one": "Katalog",
"folder_few": "katalogi",
"folder_many": "katalogów",
"folder_few": "Katalogi",
"folder_many": "Katalogów",
"smartPlaylist": "Inteligentna $t(entity.playlist, {\"count\": 1})",
"album_one": "Album",
"album_few": "albumy",
"album_many": "albumów",
"album_few": "Albumy",
"album_many": "Albumów",
"genreWithCount_one": "{{count}} gatunek",
"genreWithCount_few": "{{count}} gatunki",
"genreWithCount_many": "{{count}} gatunków",
@@ -700,7 +700,8 @@
"sleepTimer_setCustom": "Ustaw wyłącznik",
"sleepTimer_cancel": "Anuluj wyłączanie",
"albumRadio": "Radio albumu",
"scrobbleForceSubmit": "Wymuś scrobble"
"scrobbleForceSubmit": "Wymuś scrobble",
"sleepTimer_endOfAlbum": "Koniec aktualnego albumu"
},
"setting": {
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
+172 -9
View File
@@ -174,7 +174,8 @@
"explicitStatus": "Признак нецензурного контента",
"newVersionAvailable": "Доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов",
"back": "Назад"
"back": "Назад",
"openFolder": "Открыть папку"
},
"entity": {
"album_one": "Альбом",
@@ -240,7 +241,10 @@
"table": {
"config": {
"view": {
"table": "Таблица"
"table": "Таблица",
"detail": "Детали",
"grid": "Сетка",
"list": "Список"
},
"general": {
"displayType": "Тип отображения",
@@ -250,7 +254,29 @@
"followCurrentSong": "Следовать за исполняемым треком",
"size": "$t(common.size)",
"itemSize": "Размер элементов (px)",
"itemGap": "Отступ между элементами (px)"
"itemGap": "Отступ между элементами (px)",
"advancedSettings": "Расширенные настройки",
"autosize": "Автоматический выбор размера",
"moveUp": "Переместить выше",
"moveDown": "Переместить ниже",
"pinToLeft": "Закрепить слева",
"pinToRight": "Закрепить права",
"alignLeft": "Выровнять по левой стороне",
"alignCenter": "Выровнять по центру",
"alignRight": "Выровнять по правой стороне",
"itemsPerRow": "Элементов в строке",
"size_default": "По-умолчанию",
"size_compact": "Компактный",
"size_large": "Большой",
"pagination": "Пагинация",
"pagination_itemsPerPage": "Элементов на странице",
"pagination_infinite": "Бесконечно",
"pagination_paginate": "Разбитый по страницам",
"alternateRowColors": "Переменный цвет строк",
"horizontalBorders": "Границы строки",
"rowHoverHighlight": "Подсветка строки при наведении",
"showHeader": "Показать заголовок",
"verticalBorders": "Границы колонки"
},
"label": {
"releaseDate": "Дата выхода",
@@ -276,7 +302,10 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"codec": "$t(common.codec)",
"titleArtist": "$t(common.title) (артист)"
"titleArtist": "$t(common.title) (артист)",
"albumGroup": "Группа альбома",
"composer": "Композитор",
"image": "Изображение"
}
},
"column": {
@@ -299,7 +328,14 @@
"comment": "Комментарий",
"bitrate": "Битрейт",
"channels": "$t(common.channel_other)",
"bpm": "BPM"
"bpm": "BPM",
"albumCount": "Альбомы",
"artist": "Исполнители",
"bitDepth": "Битовая глубина",
"genre": "Жанр",
"sampleRate": "Частота дискретизации",
"songCount": "Треки",
"owner": "Правообладатель"
}
},
"error": {
@@ -434,7 +470,8 @@
"sleepTimer_setCustom": "Установить таймер",
"sleepTimer_custom": "Пользовательский",
"sleepTimer_cancel": "Отменить таймер",
"scrobbleForceSubmit": "Принудительная скробблинг"
"scrobbleForceSubmit": "Принудительная скробблинг",
"sleepTimer_endOfAlbum": "Конец этого альбома"
},
"page": {
"sidebar": {
@@ -756,7 +793,12 @@
"input_played_optionAll": "Все треки",
"input_played_optionUnplayed": "Только не игранные треки",
"input_played_optionPlayed": "Только воспроизведённые треки",
"input_genre": "$t(entity.genre, {\"count\": 1})"
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_kind_albums": "Альбомы",
"input_kind_songs": "Песни",
"input_kind": "Случайный выбор",
"input_limit_albums": "Сколько альбомов?",
"input_limit_songs": "Сколько песен?"
},
"editRadioStation": {
"success": "Радиостанция успешно обновлена"
@@ -1087,7 +1129,78 @@
"audioFadeOnStatusChange": "плавное изменение звука",
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
"preventSleepOnPlayback": "не переходить в спящий режим"
"preventSleepOnPlayback": "не переходить в спящий режим",
"autoDJ_mode": "Режим",
"autoDJ_mode_albums": "Альбомы",
"autoDJ_mode_description": "Добавь песни или целые альбомы в очередь",
"autoDJ_mode_songs": "Песни",
"autoDJ_enabled": "Включить Auto DJ",
"autoDJ_albumStrategy": "Режим выбора альбома",
"autoDJ_songStrategy": "Режим выбора песни",
"autoDJ_strategy_option_library_random": "Случайно",
"autoDJ_strategy_option_similar": "Похожие",
"hotkey_listShowPlayingSong": "Показать текущую песню в списке",
"listenbrainz_description": "Показать ссылки на ListenBrains на страницах исполнителя/альбома",
"listenbrainz": "Показать ссылки на ListenBrainz",
"qobuz_description": "Показать ссылки на Qobuz на страницах исполнителя/альбома",
"qobuz": "Показать ссылки на Qobuz",
"spotify_description": "Показать ссылки на Spotify на странице исполнителя/альбома",
"spotify": "Показать ссылки на Spotify",
"nativeSpotify_description": "Открывать в приложении Spotify вместо браузера",
"nativeSpotify": "Использовать приложение Spotify",
"imageResolution_optionTable": "Таблица",
"preventSuspendOnPlayback_description": "Не приостанавливать приложение во время проигрывания музыки",
"preventSuspendOnPlayback": "Не приостанавливать во время проигрывания",
"playerItemConfiguration_description": "Настроить какие элементы и в каком порядке видны в полноэкранном плеере",
"playerItemConfiguration": "Настройка плеера",
"sidebarPlaylistFolders": "Включить папки",
"sidebarPlaylistFolderSeparator_description": "Символ (или строка), который разделяет уровни папок в названии плейлиста",
"sidebarPlaylistFolderSeparator": "Разделитель папок",
"sidebarPlaylistFolderView_description": "Как отображать папки в боковой панели",
"sidebarPlaylistFolderView": "Вид папок",
"sidebarPlaylistFolderView_optionSingle": "Единстванная папка",
"sidebarPlaylistFolderView_optionTree": "Вид дерева",
"sidebarPlaylistFolderView_optionNavigation": "Вид навигации",
"sidebarPlaylistFolderTreeIndent_description": "Отступ в пикселях на каждом уровне дерева",
"sidebarPlaylistFolderTreeIndent": "Отступ в дереве",
"sidebarPlaylistFolderTreeLineColor_description": "Цвет линий соединения в дереве (оставь пустым, чтобы использовать настройки темы)",
"sidebarPlaylistFolderTreeLineColor": "Цвет линии в дереве",
"sidebarPlaylistMode_description": "Как отображать каждый плейлист в списке в боковой панели",
"sidebarPlaylistMode": "Режим плейлиста в боковой панели",
"sidebarPlaylistMode_optionCompact": "Компактный",
"sidebarPlaylistMode_optionExpanded": "Просторный",
"sidebarPlaylistSorting_description": "Разрешить ручную сортировку плейлистов в боковой панели с помощью перетаскивания вместо сортировки со стороны сервера",
"sidebarPlaylistSorting": "Сортировка плейлистов в боковой панели",
"sidebarPlaylistListFilterRegex_description": "Скрывать плейлисты в боковой панели, которые соответствуют этому регулярному выражению",
"sidebarPlaylistListFilterRegex_placeholder": "Например ^daily mix.*",
"sidebarPlaylistListFilterRegex": "Регулярное выражение для фильтрации плейлистов",
"sidePlayQueueLayout": "Макет очереди проигрывания сбоку",
"sidePlayQueueLayout_description": "Задает макет прикрепленной очереди проигрывания сбоку",
"sidePlayQueueLayout_optionHorizontal": "Горизонтальный",
"sidePlayQueueLayout_optionVertical": "Вертикальный",
"mediaSession_description": "Включает интеграцию сессии медиа, отображая элементы управления и метаданные медиа в системном оверлее управления громкостью и на экране блокировки. Требуется Web Audio Player.",
"mediaSession": "Включить сессию медиа",
"skipPlaylistPage_description": "Когда переходишь в плейлист, откроется страница со списком песен плейлиста, вместо страницы по-умолчанию",
"transcode": "Включить транскодирование",
"transcodeFormat_description": "Выбирает форматы для транскодирования. Оставь пустым, чтобы решение принимал сервер",
"translationApiKey_description": "Ключ API для перевода (только эндпойнт глобального сервиса)",
"translationApiKey": "Ключ API перевода",
"translationApiProvider_description": "Поставщик API для перевода",
"translationApiProvider": "Поставщик API перевода",
"translationTargetLanguage_description": "На какой язык выполнять перевод",
"translationTargetLanguage": "На какой язык переводить",
"trayEnabled_description": "Показать/скрыть иконку/меню в трее. Если скрыто, то также отключается сворачивать в трей/свернуть в трей при выходе",
"trayEnabled": "Показать в трее",
"queryBuilder": "Создатель очереди",
"queryBuilderCustomFields_inputLabel": "Метка",
"queryBuilderCustomFields_inputTag": "Тег",
"queryBuilderCustomFields": "Пользовательские поля",
"queryBuilderCustomFields_description": "Добавь пользовательские поля для использования создателями очереди",
"hotkey_listNavigateToPage": "Перейти к странице элемента",
"hotkey_listPlayDefault": "Воспроизвести список",
"hotkey_listPlayLast": "Воспроизвести последний в списке",
"hotkey_listPlayNext": "Воспроизвести следующий в списке",
"sidebarPlaylistFolders_description": "Создать вид папки для плейлистов, которые включают настраиваемый разделитель в имени"
},
"releaseType": {
"secondary": {
@@ -1162,6 +1275,56 @@
"presetName": "Название пресета",
"presetNamePlaceholder": "Введите название пресета",
"general": "Главная",
"lineWidth": "Ширина линии"
"lineWidth": "Ширина линии",
"systemAudioConsentAllow": "Разрешить",
"systemAudioConsentBody": "Для работы визуализатора требуется доступ к аудио в системе",
"systemAudioConsentDecline": "Запретить",
"systemAudioConsentTitle": "Разрешить доступ к аудио в системе?",
"systemAudioCaptureFailed": "Не удается начать захват: {{message}}",
"visualizerType": "Тип визуализатора",
"cyclePresets": "Переключаться между наборами настроек",
"cycleTime": "Время между переключениями (в секундах)",
"includeAllPresets": "Включить все наборы настроек",
"ignoredPresets": "Игнорируемые наборы настроек",
"selectedPresets": "Выбранные наборы настроек",
"randomizeNextPreset": "Выбирать следующий набор настроек случайным образом",
"blendTime": "Время смешивания",
"mode": "Режим",
"mode1To8": "Режимы 1-8",
"mode10": "Режим 10",
"maxFPS": "Максимум кадров в секунду",
"opacity": "Прозрачность",
"customGradients": "Пользовательские градиенты",
"addCustomGradient": "Добавить пользовательский градиент",
"gradientName": "Название градиента",
"gradientNamePlaceholder": "Название градиента",
"vertical": "Вертикальный",
"horizontal": "Горизонтальный",
"addColor": "Добавить цвет",
"position": "Расположение",
"level": "Уровень",
"remove": "Удалить",
"pasteGradient": "Вставить градиент",
"pasteGradientPlaceholder": "Вставить JSON с градиентом сюда...",
"custom": "Пользовательский",
"builtIn": "Встроенный",
"colors": "Цвета",
"colorMode": "Цветовой режим",
"gradient": "Градиент",
"gradientLeft": "Градиент слева",
"gradientRight": "Градиент справа",
"smoothing": "Сглаживание",
"minimumFrequency": "Минимальная частота",
"maximumFrequency": "Максимальная частота",
"sensitivity": "Чуствительность",
"minimumDecibels": "Минимум децибел",
"maximumDecibels": "Максимум децибел",
"linearAmplitude": "Линейная амплитуда",
"showPeaks": "Показывать пики"
},
"dragDropZone": {
"error_oneFileOnly": "Выбери только 1 файл",
"error_readingFile": "Проблема при чтении файла: {{errorMessage}}",
"mainText": "Перемести файл сюда"
}
}
+30 -29
View File
@@ -5,7 +5,7 @@
"bitrate": "位元率",
"bpm": "BPM",
"clear": "清空",
"collapse": "疊",
"collapse": "疊",
"comingSoon": "即將推出…",
"confirm": "確認",
"decrease": "降低",
@@ -18,9 +18,9 @@
"add": "新增",
"areYouSure": "你確定嗎?",
"ascending": "升冪",
"disable": "用",
"disable": "用",
"disc": "光碟",
"dismiss": "不再顯示",
"dismiss": "不理會",
"duration": "時長",
"edit": "編輯",
"enable": "啟用",
@@ -64,9 +64,9 @@
"cancel": "取消",
"center": "中央",
"channel_other": "聲道",
"configure": "設定",
"configure": "配置",
"create": "建立",
"currentSong": "目前$t(entity.track, {\"count\": 1})",
"currentSong": "當前 $t(entity.track, {\"count\": 1})",
"minimize": "最小化",
"modified": "已修改",
"name": "名稱",
@@ -75,13 +75,13 @@
"noResultsFromQuery": "未查詢到匹配結果",
"note": "注釋",
"additionalParticipants": "額外參與者",
"newVersion": "已安裝新版本 ({{version}})",
"newVersion": "新版本 ({{version}}) 已被安裝",
"viewReleaseNotes": "查看發行註記",
"albumGain": "專輯增益",
"albumPeak": "專輯峰值",
"bitDepth": "位元深度",
"close": "關閉",
"codec": "編",
"codec": "編解碼器",
"mbid": "MusicBrainz ID",
"preview": "預覽",
"reload": "重新載入",
@@ -107,7 +107,7 @@
"explicit": "露骨",
"gridRows": "網格行",
"noFilters": "未設定任何過濾器",
"countSelected": "{{count}}個已選取",
"countSelected": "{{count}} 個已選取",
"retry": "重試",
"example": "範例",
"mood": "情緒",
@@ -204,7 +204,7 @@
},
"appMenu": {
"openBrowserDevtools": "開啟瀏覽器開發者工具",
"collapseSidebar": "疊側邊欄",
"collapseSidebar": "疊側邊欄",
"expandSidebar": "展開側邊欄",
"goBack": "返回",
"goForward": "前進",
@@ -381,7 +381,7 @@
"playbackSpeed": "播放速度",
"playRandom": "隨機播放",
"previous": "上一首",
"queue_clear": "清空播放佇列",
"queue_clear": "清空佇列",
"queue_remove": "移除所選",
"repeat": "循環",
"repeat_all": "全部循環",
@@ -420,7 +420,8 @@
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器",
"albumRadio": "專輯電台",
"scrobbleForceSubmit": "強制紀錄"
"scrobbleForceSubmit": "強制紀錄",
"sleepTimer_endOfAlbum": "專輯播完時"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -445,7 +446,7 @@
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
"customFontPath": "自訂字型路徑",
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
"disableLibraryUpdateOnStartup": "用啟動時檢查新版本",
"disableLibraryUpdateOnStartup": "用啟動時檢查新版本",
"discordApplicationId": "{{discord}} 應用程式 ID",
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}}",
"discordIdleStatus": "顯示 Rich Presence 閒置狀態",
@@ -531,8 +532,8 @@
"showSkipButton": "顯示跳過按鈕",
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
"sidebarPlaylistList": "側邊欄播放清單列表",
"sidebarCollapsedNavigation": "側邊欄(已疊)導航",
"sidebarCollapsedNavigation_description": "在疊的側邊欄中顯示或隱藏導航",
"sidebarCollapsedNavigation": "側邊欄(已疊)導航",
"sidebarCollapsedNavigation_description": "在疊的側邊欄中顯示或隱藏導航",
"sidebarConfiguration": "側邊欄設定",
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單清單",
@@ -561,7 +562,7 @@
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
"followLyric_description": "滾動歌詞到目前播放位置",
"font": "字型",
"globalMediaHotkeys_description": "啟用或用系統媒體快捷鍵以控制播放",
"globalMediaHotkeys_description": "啟用或用系統媒體快捷鍵以控制播放",
"hotkey_browserBack": "瀏覽器返回",
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
"hotkey_playbackStop": "停止",
@@ -572,7 +573,7 @@
"remotePassword": "遠端控制伺服器密碼",
"remotePassword_description": "設定遠端控制伺服器的密碼。這些憑證預設以不安全的方式傳輸,因此您應該使用一個您不在意的唯一密碼",
"remotePort_description": "設定遠端控制伺服器的連接埠",
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被用",
"remoteUsername_description": "設定遠端控制伺服器的使用者名稱。如果使用者名稱和密碼都為空,則身分驗證將被用",
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
"showSkipButtons": "顯示跳過按鈕",
"themeDark_description": "應用程式將使用深色主題",
@@ -932,34 +933,34 @@
"title": "標題",
"trackNumber": "曲目編號",
"size": "大小",
"codec": "編",
"codec": "編解碼器",
"owner": "擁有者",
"bitDepth": "位元深度",
"sampleRate": "取樣率"
}
},
"action": {
"addToFavorites": "新增$t(entity.favorite, {\"count\": 2})",
"clearQueue": "清空播放佇列",
"createPlaylist": "建立$t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
"addToPlaylist": "新增$t(entity.playlist, {\"count\": 1})",
"addToFavorites": "新增$t(entity.favorite, {\"count\": 2})",
"clearQueue": "清空佇列",
"createPlaylist": "建立 $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "刪除 $t(entity.playlist, {\"count\": 1})",
"addToPlaylist": "新增$t(entity.playlist, {\"count\": 1})",
"deselectAll": "取消全選",
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
"goToPage": "前往頁面",
"moveToBottom": "移至底部",
"moveToTop": "移至頂部",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "從$t(entity.favorite, {\"count\": 2})移除",
"removeFromPlaylist": "從$t(entity.playlist, {\"count\": 1})移除",
"removeFromQueue": "從播放佇列中移除",
"removeFromFavorites": "從 $t(entity.favorite, {\"count\": 2}) 移除",
"removeFromPlaylist": "從 $t(entity.playlist, {\"count\": 1}) 移除",
"removeFromQueue": "從佇列中移除",
"setRating": "評分",
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
"viewPlaylists": "查看$t(entity.playlist, {\"count\": 2})",
"toggleSmartPlaylistEditor": "切換 $t(entity.smartPlaylist) 編輯器",
"viewPlaylists": "查看 $t(entity.playlist, {\"count\": 2})",
"moveToNext": "移至下一項",
"openIn": {
"lastfm": "在Last.fm開啟",
"musicbrainz": "在MusicBrainz開啟",
"lastfm": "在 Last.fm開啟",
"musicbrainz": "在 MusicBrainz 開啟",
"spotify": "在 Spotify 中開啟",
"listenbrainz": "在 ListenBrainz 中開啟",
"qobuz": "在 Qobuz 中開啟"
+1 -1
View File
@@ -72,7 +72,7 @@ export const orderSearchResults = (args: {
searchResults = Array.from(combinedResults.values());
} else {
searchResults = fuse.search<InternetProviderLyricSearchResponse>({
searchResults = fuse.search({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
+8
View File
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
}
});
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
if (data.value === null) {
store.delete(data.property);
} else {
store.set(data.property, data.value);
}
});
ipcMain.handle('password-get', (_event, server: string): null | string => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
+59 -10
View File
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
protocol.registerSchemesAsPrivileged([
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
]);
process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error);
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
}
});
const FONT_HEADERS = [
const FONT_HEADERS = new Set([
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
];
]);
const bytesToInt = (array: Uint8Array, length: number): number => {
let value = 0;
for (let i = 0; i < length; i++) {
value = (value << 8) + array[i];
}
return value;
};
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
0x4f54544f, // font/otf
0x774f4632, // font/woff2
0x774f4646, // font/woff
]);
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
0x0001000000, // ttf, collection, sfnt
]);
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
app.whenReady()
.then(() => {
protocol.handle('feishin', async (request) => {
const filePath = `file:${request.url.slice('feishin:'.length)}`;
const response = await net.fetch(filePath);
const contentType = response.headers.get('content-type');
if (!contentType || !FONT_HEADERS.includes(contentType)) {
protocol.handle('feishin', async () => {
const filePath = store.get('local_font_path');
if (typeof filePath !== 'string') {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
});
}
return response;
const response = await net.fetch('file:' + filePath);
const contentType = response.headers.get('content-type');
// On Linux, the mime type is included in the response header
// In this case, we can forward the response with no further processing
if (contentType && FONT_HEADERS.has(contentType)) {
return response;
}
// Otherwise, let's check the magic number to see if
// the file is a font type. This is either four or five bytes
const payload = await response.arrayBuffer();
const magicNumber = new Uint8Array(payload.slice(0, 5));
const fiveHex = bytesToInt(magicNumber, 5);
const fourHex = bytesToInt(magicNumber, 4);
if (
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
) {
// We have to create a new response with the payload, since it has been read now
return new Response(payload, {
headers: response.headers,
});
}
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+8
View File
@@ -9,6 +9,13 @@ const set = (
ipcRenderer.send('settings-set', { property, value });
};
const setSync = async (
property: string,
value: boolean | null | Record<string, unknown> | string | string[],
) => {
return ipcRenderer.invoke('settings-set-sync', { property, value });
};
const get = async (property: string) => {
return ipcRenderer.invoke('settings-get', { property });
};
@@ -99,6 +106,7 @@ export const localSettings = {
passwordSet,
restart,
set,
setSync,
setZoomFactor,
themeSet,
};
+1
View File
@@ -139,6 +139,7 @@ export const utils = {
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
separator: isWindows() ? '\\' : '/',
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
+36
View File
@@ -54,6 +54,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deleteArtistImage,
400: jfType._response.error,
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
@@ -63,6 +72,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deletePlaylistImage,
400: jfType._response.error,
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
@@ -356,6 +374,24 @@ export const contract = c.router({
400: jfType._response.error,
},
},
uploadArtistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadArtistImage,
400: jfType._response.error,
},
},
uploadPlaylistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadPlaylistImage,
400: jfType._response.error,
},
},
});
const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
import {
albumArtistListSortMap,
albumListSortMap,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
Folder,
genreListSortMap,
ImageArgs,
@@ -29,6 +34,10 @@ import {
SortOrder,
sortOrderMap,
Tag,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const getImageContentType = (bytes: Uint8Array): string => {
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
return 'image/png';
}
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
return 'image/jpeg';
}
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
return 'image/gif';
}
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
return 'image/webp';
}
return 'image/jpeg';
};
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
const deleteItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
errorMessage: string,
): Promise<boolean> => {
const res = await jfApiClient({
...apiClientProps,
server: apiClientProps.server ?? null,
}).deleteArtistImage({
params: {
id,
},
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
const uploadItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
image: Uint8Array,
errorMessage: string,
): Promise<boolean> => {
const server = apiClientProps.server;
const serverUrl = getServerUrl(server);
if (!serverUrl) {
throw new Error('Server is required');
}
const contentType = getImageContentType(image);
const base64 = uint8ArrayToBase64(image);
const authHeader = createAuthHeader();
const authorization = server?.credential
? authHeader.concat(`, Token="${server.credential}"`)
: authHeader;
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
},
signal: apiClientProps.signal,
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
// Limit the query to 50 at a time to be *extremely* conservative on the
// length of the full URL, since the ids are part of the query string and
// not the POST body
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
[ServerFeature.PUBLIC_PLAYLIST]: [1],
},
],
['10.0.0', { [ServerFeature.TAGS]: [1] }],
[
'10.0.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
[ServerFeature.TAGS]: [1],
},
],
];
const JF_FIELDS = {
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
id: res.body.Id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
},
deleteFavorite: async (args) => {
const { apiClientProps, query } = args;
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
},
getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -1677,6 +1793,17 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
}
if (query.event === 'stop') {
jfApiClient(apiClientProps).scrobbleStopped({
body: {
ItemId: query.id,
PositionTicks: position,
},
});
return null;
}
jfApiClient(apiClientProps).scrobbleProgress({
body: {
ItemId: query.id,
@@ -1847,6 +1974,28 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload artist image',
);
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload playlist image',
);
},
};
function getLibraryId(musicFolderId?: string | string[]) {
@@ -2309,7 +2309,7 @@ export const SubsonicController: InternalControllerEndpoint = {
const { apiClientProps, query } = args;
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
if (query.submission) {
if (query.submission || query.event === 'start') {
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
@@ -2321,38 +2321,54 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to scrobble');
}
return null;
if (query.submission) {
return null;
}
}
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
const defaultParams = {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
};
const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => {
return ssApiClient(apiClientProps).reportPlayback({
query: {
...defaultParams,
state,
},
});
};
const promises: Promise<any>[] = [];
switch (query.event) {
case 'pause':
state = 'paused';
promises.push(reportPlayback('paused'));
break;
case 'start':
state = 'starting';
promises.push(reportPlayback('starting'));
promises.push(reportPlayback('playing'));
break;
case 'stop':
promises.push(reportPlayback('stopped'));
break;
case 'unpause':
state = 'playing';
promises.push(reportPlayback('playing'));
break;
default:
state = 'playing';
break;
}
const res = await ssApiClient(apiClientProps).reportPlayback({
query: {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
state,
},
});
for (const promise of promises) {
const res = await promise;
if (res.status !== 200) {
throw new Error('Failed to report playback');
if (res.status !== 200) {
throw new Error('Failed to report playback');
}
}
return null;
@@ -13,7 +13,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
export const getListQueryKeyName = (itemType: LibraryItem): string => {
switch (itemType) {
@@ -108,8 +108,19 @@ export const useItemListInfiniteLoader = ({
[serverId, itemType, query],
);
const isRandomSort = query?.sortBy === SortKeyRandom;
const fetchPage = useCallback(
async (pageNumber: number) => {
if (isRandomSort) {
const existingData =
queryClient.getQueryData<InfiniteLoaderCacheData>(dataQueryKey);
if (existingData?.pagesLoaded?.[pageNumber]) {
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
return;
}
}
const startIndex = pageNumber * itemsPerPage;
const queryParams = {
limit: itemsPerPage,
@@ -118,6 +129,7 @@ export const useItemListInfiniteLoader = ({
};
const result = await queryClient.fetchQuery({
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
queryFn: async ({ signal }) => {
const result = await listQueryFn({
apiClientProps: { serverId, signal },
@@ -127,6 +139,7 @@ export const useItemListInfiniteLoader = ({
return result;
},
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
});
// Update the query data with the fetched page
@@ -154,13 +167,32 @@ export const useItemListInfiniteLoader = ({
// Track the last fetched page
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
},
[itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],
[
itemsPerPage,
query,
queryClient,
serverId,
dataQueryKey,
listQueryFn,
itemType,
isRandomSort,
],
);
// Reset the loaded pages and refetch current page when the query changes
useEffect(() => {
const currentDataQueryKey = JSON.stringify(dataQueryKey);
if (isRandomSort) {
const existingData = queryClient.getQueryData<InfiniteLoaderCacheData | undefined>(
dataQueryKey,
);
if (existingData?.dataMap && existingData.dataMap.size > 0) {
previousDataQueryKeyRef.current = currentDataQueryKey;
return;
}
}
if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {
return;
}
@@ -12,7 +12,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
const getQueryKeyName = (itemType: LibraryItem): string => {
switch (itemType) {
@@ -76,6 +76,8 @@ export const useItemListPaginatedLoader = ({
const fetchRange = getFetchRange(currentPage, itemsPerPage);
const startIndex = fetchRange.startIndex;
const isRandomSort = query?.sortBy === SortKeyRandom;
const queryParams = useMemo(
() => ({
limit: itemsPerPage,
@@ -86,7 +88,7 @@ export const useItemListPaginatedLoader = ({
);
const { data } = useQuery({
gcTime: 1000 * 15,
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
placeholderData: { items: getInitialData(itemsPerPage) },
queryFn: async ({ signal }) => {
const result = await listQueryFn({
@@ -97,7 +99,7 @@ export const useItemListPaginatedLoader = ({
return result;
},
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
staleTime: 1000 * 15,
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
});
const refreshMutation = useMutation({
+43 -17
View File
@@ -109,12 +109,8 @@ export function computeSelectedFromResult(
};
}
const hasLocalLocal =
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
// If setting is set to prefer local lyrics, return the local lyrics if available
if (preferLocalLyrics && hasLocalLocal) {
if (preferLocalLyrics && hasLocalLyrics(local)) {
if (Array.isArray(local) && local.length > 0) {
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
return { selected: item, selectedSynced: item.synced };
@@ -236,6 +232,13 @@ export function getDisplayOffset(
return storedOffsetMs;
}
export function hasLocalLyrics(local: FullLyricsMetadata | null | StructuredLyric[]): boolean {
return (
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics))
);
}
const emptyResult = (): LyricsQueryResult => ({
local: null,
overrideData: null,
@@ -277,16 +280,11 @@ export const lyricsQueries = {
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
// Fetch local lyrics
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
// Fetch remote auto lyrics
const remoteAutoPromise =
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
? null
: fetchRemoteLyricsAuto(song);
// Fetch override data
const overrideDataPromise = overrideSelection
? fetchRemoteLyricsById({
remoteSongId: overrideSelection.id,
@@ -295,11 +293,40 @@ export const lyricsQueries = {
})
: null;
const [local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
let local: FullLyricsMetadata | null | StructuredLyric[];
let remoteAuto: FullLyricsMetadata | null;
let overrideData: LyricsResponse | null;
if (preferLocalLyrics) {
local = await localPromise;
if (hasLocalLyrics(local)) {
overrideData = overrideDataPromise ? await overrideDataPromise : null;
remoteAuto = null;
if (remoteAutoPromise) {
void remoteAutoPromise.then((fetchedRemoteAuto) => {
if (signal.aborted || !fetchedRemoteAuto) return;
queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>
prev ? { ...prev, remoteAuto: fetchedRemoteAuto } : prev,
);
});
}
} else {
[remoteAuto, overrideData] = await Promise.all([
remoteAutoPromise,
overrideDataPromise,
]);
}
} else {
[local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
}
const partial: Pick<
LyricsQueryResult,
@@ -320,13 +347,12 @@ export const lyricsQueries = {
preferLocalLyrics,
selectedStructuredIndex,
);
const displayOffset = getDisplayOffset(
const resultSelectedOffsetMs = getDisplayOffset(
selected,
selectedOffsetMs,
selectedStructuredIndex,
local,
);
const resultSelectedOffsetMs = displayOffset;
return {
...emptyResult(),
@@ -1,6 +1,6 @@
import { useIsFetching } from '@tanstack/react-query';
import { t } from 'i18next';
import { RefObject } from 'react';
import { RefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './play-queue-list-controls.module.css';
@@ -21,6 +21,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { toast } from '/@/shared/components/toast/toast';
import { ServerFeature } from '/@/shared/types/features-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
const { isPending: isSavingQueue, mutate: saveQueue } = useSaveQueue();
const handleSaveQueue = useCallback(() => {
saveQueue(undefined, {
onSuccess: () => {
toast.success({
message: t('form.saveQueue.success'),
});
},
});
}, [saveQueue]);
const handleRestoreQueue = useRestoreQueue();
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
import {
usePlayerShuffle,
usePlayerStatus,
usePlayerStoreBase,
} from '/@/renderer/store/player.store';
import {
useSleepTimerActions,
useSleepTimerActive,
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const },
{ minutes: 0, mode: 'endOfAlbum' as const },
{ minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const },
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
const useSleepTimer = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions();
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
// End of album mode. Set the pauseOnNextSongEnd flag whenever the current track
// is the last one of the target album.
const evaluateEndOfAlbum = useCallback(() => {
const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData();
if (!currentSong) {
return;
}
let target = useSleepTimerStore.getState().targetAlbumId;
if (target === null) {
target = currentSong.albumId;
setTargetAlbumId(target);
}
if (currentSong.albumId !== target) {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
cancelTimer();
return;
}
const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId;
usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum);
}, [cancelTimer, setTargetAlbumId]);
const handleOnCurrentSongChange = useCallback(() => {
if (!active) {
return;
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
if (mode === 'endOfSong') {
cancelTimer();
mediaPauseRef.current();
return;
}
}, [active, mode, cancelTimer, mediaPauseRef]);
// Cancel and pause song change in end-of-album mode
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
}
}, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]);
const status = usePlayerStatus();
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
// mediaAutoNext returns PAUSED status when the current song ends.
// This is a generic player mechanism — the web player handles it
// without needing to know about the sleep timer.
// End-of-album mode: set the same flag conditionally, here we run
// the intial evaluation in case the timer was started while already
// on the last track of the album
useEffect(() => {
if (!active || mode !== 'endOfSong') return;
if (!active) {
return;
}
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
if (mode === 'endOfSong') {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}, [active, mode]);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
return undefined;
}, [active, mode, evaluateEndOfAlbum]);
};
export const SleepTimerHookInner = () => {
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
useSleepTimerActions();
const { mediaPause } = usePlayer();
const shuffle = usePlayerShuffle();
// Track level shuffle scatters and album across a play queue making 'end-of-album'
// meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album
// enabled there
const isTrackShuffle = shuffle === PlayerShuffle.TRACK;
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0);
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
(option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
startEndOfSongTimer();
} else if (option.mode === 'endOfAlbum') {
startEndOfAlbumTimer();
} else {
startTimedTimer(option.minutes * 60);
}
setShowCustom(false);
setOpened(false);
},
[startEndOfSongTimer, startTimedTimer],
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
);
const handleCustomStart = useCallback(() => {
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong');
}
if (option.mode === 'endOfAlbum') {
return t('player.sleepTimer_endOfAlbum');
}
if (option.minutes >= 60) {
return t('player.sleepTimer_hours', {
count: option.minutes / 60,
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong')}
</Text>
) : mode === 'endOfAlbum' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfAlbum')}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
</Flex>
)}
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
(option, index) => (
{PRESET_OPTIONS.filter(
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
).map((option) => {
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
return (
<Button
disabled={disabled}
fullWidth
justify="flex-start"
key={index}
key={option.mode}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
>
{getPresetLabel(option)}
</Button>
),
)}
);
})}
<Divider my="md" />
@@ -170,26 +170,20 @@ export const useSaveQueue = () => {
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
}
try {
await api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
toast.success({
message: t('form.saveQueue.success'),
});
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
throw error;
}
return api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
},
onError: (error) => {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
},
});
@@ -67,8 +67,9 @@ Jellyfin progress APIs still use playback position (ticks), not listen time:
- pause / unpause
Other events:
- When the song changes: sends 'start' when the new track is playing;
clears submission flag and listen accumulator for the new track.
- When the song changes: sends 'stop' for the previous track; sends 'start'
when the new track is playing; clears submission flag and listen accumulator
for the new track.
- When the song is restarted (near 0 after 10s+): clears submission flag
and listen accumulator.
@@ -129,6 +130,7 @@ export const useScrobble = () => {
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const stopPositionRef = useRef<number>(0);
const lastProgressEventRef = useRef<number>(0);
const lastSeekEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -316,7 +318,10 @@ export const useScrobble = () => {
) => {
const currentSong = properties.song;
const previousSong = previousSongRef.current;
const previousPositionSec = stopPositionRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const previousMediaType = previousSong?._itemType.includes('song') ? 'song' : 'podcast';
const useTicksForPrevious = previousSong?._serverType === ServerType.JELLYFIN;
// Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) {
@@ -352,6 +357,7 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) {
previousSongRef.current = currentSong;
previousTimestampRef.current = 0;
stopPositionRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
flushScrobbleDebug();
@@ -395,10 +401,42 @@ export const useScrobble = () => {
},
);
}
// Send stop scrobble for the track that was playing before the change
if (previousSong?.id) {
sendScrobble.mutate(
{
apiClientProps: { serverId: previousSong._serverId || '' },
query: {
albumId: previousSong.albumId,
event: 'stop',
id: previousSong.id,
mediaType: previousMediaType,
playbackRate: playbackRate,
position: getPositionValue(
previousPositionSec,
useTicksForPrevious,
),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
category: LogCategory.SCROBBLE,
meta: {
id: previousSong.id,
},
});
},
},
);
}
}, 2000);
previousSongRef.current = currentSong;
previousTimestampRef.current = 0;
stopPositionRef.current = 0;
flushScrobbleDebug();
},
[
@@ -591,6 +629,7 @@ export const useScrobble = () => {
isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0;
previousTimestampRef.current = 0;
stopPositionRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
@@ -625,6 +664,17 @@ export const useScrobble = () => {
// Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => {
// Preserve last playback position when the playhead resets to the start
// (song change can fire after progress already reports 0 for the new track).
if (
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
prev.timestamp >= SCROBBLE_TRACK_BEGIN_SEC
) {
stopPositionRef.current = prev.timestamp;
} else {
stopPositionRef.current = properties.timestamp;
}
previousTimestampRef.current = properties.timestamp;
handleScrobbleFromProgress(properties, prev);
flushScrobbleDebug();
@@ -224,25 +224,26 @@ export const PlaylistQueryEditor = ({
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const appliedQuery = appliedJsonState?.query;
const detailQueryRules = detailQuery?.data?.rules;
const effectiveQuery = useMemo(
() =>
appliedJsonState?.query ??
(detailQuery?.data?.rules?.all
? { all: detailQuery.data.rules.all }
: detailQuery?.data?.rules?.any
? { any: detailQuery.data.rules.any }
: detailQuery?.data?.rules),
[appliedJsonState?.query, detailQuery?.data?.rules],
appliedQuery ??
(detailQueryRules?.all
? { all: detailQueryRules.all }
: detailQueryRules?.any
? { any: detailQueryRules.any }
: detailQueryRules),
[appliedQuery, detailQueryRules],
);
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveLimitPercent =
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
const appliedSort = appliedJsonState?.sort;
const effectiveSortBy = useMemo(
() =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
| SongListSort
| SongListSort[],
[appliedJsonState?.sort, parseSortBy],
() => (appliedSort ? [appliedSort] : parseSortBy()) as SongListSort | SongListSort[],
[appliedSort, parseSortBy],
);
const effectiveSortOrder = appliedJsonState?.sort
? appliedJsonState.sort.startsWith('-')
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
control: (
<FileInput
accept=".ttc,.ttf,.otf,.woff,.woff2"
onChange={(e) =>
clearable
defaultValue={
fontSettings.custom
? new File([], fontSettings.custom.split(utils?.separator || '').pop()!)
: null
}
onChange={async (e) => {
const custom = e ? getPathForFile?.(e) || null : null;
await localSettings?.setSync('local_font_path', custom);
setSettings({
font: {
...fontSettings,
custom: e ? getPathForFile?.(e) || null : null,
custom,
},
})
}
});
}}
w={300}
/>
),
description: t('setting.customFontPath', {
context: 'description',
}),
isHidden: fontSettings.type !== FontType.CUSTOM,
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
title: t('setting.customFontPath'),
},
{
@@ -298,6 +298,7 @@ export const useServerAuthenticated = () => {
await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));
// Retry authentication
// eslint-disable-next-line react-hooks/immutability
return authenticateServer(serverWithAuth, nextRetry);
}
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
const settingsFromStore = useSettingsStore.getState();
const settings = {
font: settingsFromStore.font,
general: settingsFromStore.general,
hotkeys: settingsFromStore.hotkeys,
lyrics: settingsFromStore.lyrics,
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
mainStoreKey: 'enableNeteaseTranslation',
rendererValue: settings.lyrics.enableNeteaseTranslation,
},
{
mainStoreKey: 'local_font_path',
rendererValue: settings.font.custom,
},
];
// Compare and sync each setting
+1 -1
View File
@@ -189,7 +189,7 @@ const appRouterModals = {
export const AppRouter = () => {
const router = (
<HashRouter unstable_useTransitions={false}>
<HashRouter>
<ModalsProvider modals={appRouterModals}>
<RouterErrorBoundary>
<Routes>
+11
View File
@@ -1185,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
});
},
mediaSeekToTimestamp: (timestamp: number) => {
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(timestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
});
@@ -1196,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
// Update the timestamp store right away so the UI and any
// subsequent seek compute from the new position instead of the
// stale value left by the ~500ms engine poll (otherwise mashing
// the seek keys repeatedly lands on the same time).
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
@@ -1217,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
+25 -1
View File
@@ -1,11 +1,13 @@
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed';
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
interface SleepTimerActions {
cancelTimer: () => void;
setRemaining: (remaining: number) => void;
setTargetAlbumId: (albumId: null | string) => void;
startEndOfAlbumTimer: () => void;
startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void;
}
@@ -17,6 +19,8 @@ interface SleepTimerState {
mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */
remaining: number;
/** Album Id for song when mode activated */
targetAlbumId: null | string;
}
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: false,
mode: 'timed',
remaining: 0,
targetAlbumId: null,
});
},
mode: 'timed',
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
set({ remaining });
},
setTargetAlbumId: (albumId: null | string) => {
set({ targetAlbumId: albumId });
},
startEndOfAlbumTimer: () => {
set({
active: true,
mode: 'endOfAlbum',
remaining: 0,
targetAlbumId: null,
});
},
startEndOfSongTimer: () => {
set({
active: true,
mode: 'endOfSong',
remaining: 0,
targetAlbumId: null,
});
},
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: true,
mode: 'timed',
remaining: durationSeconds,
targetAlbumId: null,
});
},
targetAlbumId: null,
}),
);
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
setTargetAlbumId: s.setTargetAlbumId,
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),
+6
View File
@@ -47,6 +47,7 @@ export const THEME_DATA = [
{ label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },
{ label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },
{ label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },
{ label: 'Zenburn', type: 'dark', value: AppTheme.ZENBURN },
];
export const useAppTheme = (overrideTheme?: AppTheme) => {
@@ -134,6 +135,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
document.body.appendChild(textStyleRef.current);
}
// Note: we change the url to bust caches when changing the path
// The url provided here does NOT matter, validation is done
// on the main process. Any feishin:/ url will fetch the same
// item, which the renderer will check via magic number to be
// some font item
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";
+1
View File
@@ -107,6 +107,7 @@ export const logMsg = {
[LogCategory.SCROBBLE]: {
scrobbledPause: 'Scrobbled a pause event',
scrobbledStart: 'Scrobbled a start event',
scrobbledStop: 'Scrobbled a stop event',
scrobbledSubmission: 'Scrobbled a submission event',
scrobbledTimeupdate: 'Scrobbled a timeupdate event',
scrobbledUnpause: 'Scrobbled an unpause event',
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
playCount: item.UserData?.PlayCount || 0,
similarArtists,
songCount: item.SongCount ?? null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
@@ -434,6 +435,7 @@ const normalizePlaylist = (
size: null,
songCount: item?.ChildCount || null,
sync: null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
};
};
+12
View File
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
const deletePlaylist = z.null();
const deletePlaylistImage = z.null();
const deleteArtistImage = deletePlaylistImage;
const uploadPlaylistImage = z.null();
const uploadArtistImage = uploadPlaylistImage;
const deletePlaylistParameters = z.object({
Id: z.string(),
});
@@ -886,7 +894,9 @@ export const jfType = {
albumList,
authenticate,
createPlaylist,
deleteArtistImage,
deletePlaylist,
deletePlaylistImage,
error,
favorite,
filters,
@@ -912,6 +922,8 @@ export const jfType = {
studioList,
topSongsList,
updatePlaylist,
uploadArtistImage,
uploadPlaylistImage,
user,
},
};
+1
View File
@@ -34,6 +34,7 @@ export enum AppTheme {
TOKYO_NIGHT = 'tokyoNight',
VSCODE_DARK_PLUS = 'vscodeDarkPlus',
VSCODE_LIGHT_PLUS = 'vscodeLightPlus',
ZENBURN = 'zenburn',
}
export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;
+2
View File
@@ -35,6 +35,7 @@ import { solarizedLight } from '/@/shared/themes/solarized-light/solarized-light
import { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';
import { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';
import { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';
import { zenburn } from '/@/shared/themes/zenburn/zenburn';
export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
[AppTheme.AYU_DARK]: ayuDark,
@@ -68,6 +69,7 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
[AppTheme.TOKYO_NIGHT]: tokyoNight,
[AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,
[AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,
[AppTheme.ZENBURN]: zenburn,
};
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
+28
View File
@@ -0,0 +1,28 @@
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
export const zenburn: AppThemeConfiguration = {
app: {
'overlay-header':
'linear-gradient(transparent 0%, rgb(40 44 52 / 85%) 100%), var(--theme-background-noise)',
'overlay-subheader':
'linear-gradient(180deg, rgb(40 44 52 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',
},
colors: {
background: '#3f3f3f',
'background-alternate': '#313131',
black: '#313131',
foreground: '#dcdccc',
'foreground-muted': '#d9d9d9',
primary: '#95a4b2',
'state-error': '#dca3a3',
'state-info': '#95a4b2',
'state-success': '#7f9f7f',
'state-warning': '#efdcbc',
surface: '#636363',
'surface-foreground': '#95a4b2',
white: '#dcdccc',
},
mode: 'dark',
};
+7 -5
View File
@@ -465,6 +465,8 @@ export const tagListSortMap: TagListSortMap = {
},
};
export const SortKeyRandom = 'random';
export enum AlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
@@ -476,7 +478,7 @@ export enum AlbumListSort {
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
@@ -598,7 +600,7 @@ export enum SongListSort {
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
@@ -725,7 +727,7 @@ export enum AlbumArtistListSort {
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
@@ -814,7 +816,7 @@ export enum ArtistListSort {
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
@@ -1363,7 +1365,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
export type ScrobbleQuery = {
albumId?: string;
event?: 'pause' | 'start' | 'unpause';
event?: 'pause' | 'start' | 'stop' | 'unpause';
id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;