mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-05 18:19:56 +02:00
Compare commits
47 Commits
v1.12.1
...
ba4664e797
| Author | SHA1 | Date | |
|---|---|---|---|
| ba4664e797 | |||
| b14eb1c423 | |||
| 4297d0d5b3 | |||
| 64615a1701 | |||
| 2a0e414d8f | |||
| f2c455f23b | |||
| 0a0027f245 | |||
| 4f687c155f | |||
| f1f415daa8 | |||
| 1ee767352a | |||
| 66a123c10d | |||
| de0ddfe226 | |||
| d23f7619ec | |||
| 44de6f2207 | |||
| f6f25154a1 | |||
| 880516069d | |||
| 95970183db | |||
| 3a2c952d2a | |||
| 46b94a83f1 | |||
| 40a1d1438d | |||
| 905088cae7 | |||
| 705b375dab | |||
| 0b537b07ee | |||
| dfa6198bdd | |||
| b9312d86fd | |||
| 30a1bda93d | |||
| 0e24eeeb1c | |||
| 58d4dea09a | |||
| c4da44a443 | |||
| be3f959354 | |||
| deb69ef8ea | |||
| 5ac0aaeec0 | |||
| 515cadb916 | |||
| 4b4d64c7fc | |||
| f7e1198482 | |||
| 7243ed7f15 | |||
| 7e9a78898f | |||
| 6aab8d4121 | |||
| 70594a696b | |||
| 08b4c620f2 | |||
| 7a20cf3853 | |||
| dd186c570f | |||
| d5e9d491b6 | |||
| 28dc822e4f | |||
| def1b1e710 | |||
| 2ff9e4b0a2 | |||
| 49bfc907cd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -38,8 +38,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
|
||||
- name: Install Node and PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -12,7 +12,8 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
@@ -42,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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: '1.0.2'
|
||||
appimage: '1.0.3'
|
||||
|
||||
npmRebuild: false
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: '1.0.2'
|
||||
appimage: '1.0.3'
|
||||
|
||||
npmRebuild: false
|
||||
publish:
|
||||
|
||||
@@ -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
-50
@@ -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,38 +159,32 @@
|
||||
"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"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"productName": "feishin"
|
||||
}
|
||||
|
||||
Generated
+2186
-2388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
abstract-socket: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
minimumReleaseAge: 1440
|
||||
overrides:
|
||||
'xml2js': '0.5.0'
|
||||
'react-router': '7.14.0'
|
||||
@@ -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',
|
||||
|
||||
@@ -341,7 +341,8 @@
|
||||
"rename": "Reanomena",
|
||||
"newVersionAvailable": "Hi ha una nova versió disponible",
|
||||
"numberOfResults": "{{numberOfResults}} resultats",
|
||||
"back": "Enrere"
|
||||
"back": "Enrere",
|
||||
"openFolder": "Obre la carpeta"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Àlbum",
|
||||
@@ -462,7 +463,7 @@
|
||||
"expireInvalid": "La data d'expiració ha de ser al futur",
|
||||
"createFailed": "No s'ha pogut crear el recurs compartit (està habilitat, l'ús compartit?)",
|
||||
"copyToClipboard": "Copiar al porta-retalls: Ctrl+C, enter",
|
||||
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la."
|
||||
"successMustClick": "Compartició creada correctament. Feu clic aquí per obrir-la"
|
||||
},
|
||||
"updateServer": {
|
||||
"success": "S'ha actualitzat el servidor amb èxit",
|
||||
@@ -495,7 +496,12 @@
|
||||
"input_played": "Reprodueix el filtre",
|
||||
"input_played_optionAll": "Totes les pistes",
|
||||
"input_played_optionUnplayed": "Només les pistes sense reproduir",
|
||||
"input_played_optionPlayed": "Només les pistes reproduïdes"
|
||||
"input_played_optionPlayed": "Només les pistes reproduïdes",
|
||||
"input_kind_albums": "Àlbums",
|
||||
"input_kind_songs": "Cançons",
|
||||
"input_kind": "Seleccions a l'atzar",
|
||||
"input_limit_albums": "Quants àlbums?",
|
||||
"input_limit_songs": "Quantes cançons?"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "Emissora de ràdio creada amb èxit",
|
||||
@@ -626,7 +632,7 @@
|
||||
"customCssEnable_description": "Permet escriure CSS personalitzat",
|
||||
"customCssNotice": "Atenció: tot i que hi ha un filtre (no es permet ni URL() ni content:), l'ús de CSS personalitzat pot presentar riscs si canvieu la interfície",
|
||||
"customCss": "Css personalitzat",
|
||||
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixin pel filtre",
|
||||
"customCss_description": "Contingut del CSS personalitzat. Nota: la propietat \"content\" i els urls remots no es permeten. A sota hi teniu una previsualització. Els camps addicionals que no establiu hi apareixen a causa de la sanitització. Escriptori: Feishin llegeix i escriu custom.css al directori de configuració de l'aplicació i el recarrega quan el fitxer canvia",
|
||||
"customFontPath": "Ruta de font personalitzada",
|
||||
"customFontPath_description": "Estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
|
||||
"discordApplicationId": "ID d'aplicació de {{discord}}",
|
||||
@@ -807,7 +813,7 @@
|
||||
"releaseChannel": "Canal de versions",
|
||||
"releaseChannel_description": "Trieu entre versions estables i beta o alfa (diàries) per les actualitzacions automàtiques",
|
||||
"mediaSession": "Activa media session",
|
||||
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig",
|
||||
"mediaSession_description": "Activa la integració amb Media Session per mostrar els controls multimèdia i les metadades a l'indicador de volum del sistema i la pantalla de bloqueig. Requereix el Reproductor Web d'Àudio.",
|
||||
"crossfadeStyle": "Estil de fosa encadenada",
|
||||
"discordRichPresence": "Estat d'activitat de {{discord}}",
|
||||
"enableAutoTranslation_description": "Activa la traducció automàtica en carregar la lletra",
|
||||
@@ -828,7 +834,7 @@
|
||||
"transcode": "Activa la transcodificació",
|
||||
"autoDJ": "DJ automàtic",
|
||||
"autoDJ_itemCount": "Número d'elements",
|
||||
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua quan el DJ automàtic està activat",
|
||||
"autoDJ_itemCount_description": "El nombre d'elements que s'intenten afegir a la cua",
|
||||
"autoDJ_timing": "Temps",
|
||||
"autoDJ_timing_description": "El nombre de cançons que han de quedar a la cua per activar el DJ automàtic",
|
||||
"analyticsDisable": "Desactiva les analítiques basades en l'ús",
|
||||
@@ -958,7 +964,16 @@
|
||||
"sidebarPlaylistMode_description": "Com es mostra cada llista de reproducció a la llista de la barra lateral",
|
||||
"sidebarPlaylistMode": "Mode de llista de reproducció a la barra lateral",
|
||||
"sidebarPlaylistMode_optionCompact": "Compacte",
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit"
|
||||
"sidebarPlaylistMode_optionExpanded": "Expandit",
|
||||
"autoDJ_mode": "Mode",
|
||||
"autoDJ_mode_albums": "Àlbums",
|
||||
"autoDJ_mode_description": "Trieu si voleu afegir cançons o àlbums sencers a la cua",
|
||||
"autoDJ_mode_songs": "Cançons",
|
||||
"autoDJ_enabled": "Activa el DJ automàtic",
|
||||
"autoDJ_albumStrategy": "Mode de selecció d'àlbum",
|
||||
"autoDJ_songStrategy": "Mode de selecció de cançó",
|
||||
"autoDJ_strategy_option_library_random": "A l'atzar",
|
||||
"autoDJ_strategy_option_similar": "Similar"
|
||||
},
|
||||
"table": {
|
||||
"column": {
|
||||
@@ -1167,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",
|
||||
|
||||
+21
-20
@@ -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",
|
||||
@@ -296,7 +297,7 @@
|
||||
"releaseChannel": "Kanál vydání",
|
||||
"releaseChannel_description": "Vyberte si mezi stabilními, beta nebo alpha (nočními) vydáními pro automatické aktualizace",
|
||||
"mediaSession": "Povolit relaci médií",
|
||||
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce",
|
||||
"mediaSession_description": "Povolí integraci do služby Media Session, což zobrazí ovládání a metadata médií v překrytí systémové hlasitosti a na zamykací obrazovce. Vyžaduje webový přehrávač zvuku.",
|
||||
"exportImportSettings_control_description": "Exportovat a importovat nastavení pomocí souboru JSON",
|
||||
"exportImportSettings_control_exportText": "Exportovat nastavení",
|
||||
"exportImportSettings_control_importText": "Importovat nastavení",
|
||||
@@ -548,19 +549,19 @@
|
||||
"cancel": "Zrušit",
|
||||
"forceRestartRequired": "Restartujte pro použití změn… zavřete oznámení pro restartování",
|
||||
"setting_one": "Nastavení",
|
||||
"setting_few": "nastavení",
|
||||
"setting_few": "Nastavení",
|
||||
"setting_other": "Nastavení",
|
||||
"version": "Verze",
|
||||
"title": "Název",
|
||||
"filter_one": "Filtr",
|
||||
"filter_few": "filtry",
|
||||
"filter_few": "Filtry",
|
||||
"filter_other": "Filtrů",
|
||||
"filters": "Filtry",
|
||||
"create": "Vytvořit",
|
||||
"bitrate": "Datový tok",
|
||||
"saveAndReplace": "Uložit a nahradit",
|
||||
"action_one": "Akce",
|
||||
"action_few": "akce",
|
||||
"action_few": "Akce",
|
||||
"action_other": "Akcí",
|
||||
"playerMustBePaused": "Přehrávač musí být pozastaven",
|
||||
"confirm": "Potvrdit",
|
||||
@@ -569,7 +570,7 @@
|
||||
"comingSoon": "Již brzy…",
|
||||
"reset": "Resetovat",
|
||||
"channel_one": "Kanál",
|
||||
"channel_few": "kanály",
|
||||
"channel_few": "Kanály",
|
||||
"channel_other": "Kanálů",
|
||||
"disable": "Vypnout",
|
||||
"sortOrder": "Pořadí",
|
||||
@@ -1160,16 +1161,16 @@
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "Žánr",
|
||||
"genre_few": "žánry",
|
||||
"genre_few": "Žánry",
|
||||
"genre_other": "Žánry",
|
||||
"playlistWithCount_one": "{{count}} playlist",
|
||||
"playlistWithCount_few": "{{count}} playlisty",
|
||||
"playlistWithCount_other": "{{count}} playlistů",
|
||||
"playlist_one": "Playlist",
|
||||
"playlist_few": "playlisty",
|
||||
"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",
|
||||
@@ -1178,7 +1179,7 @@
|
||||
"albumArtist_few": "Umělci alb",
|
||||
"albumArtist_other": "Umělci alb",
|
||||
"track_one": "Skladba",
|
||||
"track_few": "skladby",
|
||||
"track_few": "Skladby",
|
||||
"track_other": "Skladby",
|
||||
"albumArtistCount_one": "{{count}} umělec alba",
|
||||
"albumArtistCount_few": "{{count}} umělci alba",
|
||||
@@ -1187,17 +1188,17 @@
|
||||
"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",
|
||||
"artistWithCount_other": "{{count}} umělců",
|
||||
"folder_one": "Složka",
|
||||
"folder_few": "složky",
|
||||
"folder_few": "Složky",
|
||||
"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",
|
||||
@@ -1208,9 +1209,9 @@
|
||||
"play_one": "{{count}} přehrání",
|
||||
"play_few": "{{count}} přehrání",
|
||||
"play_other": "{{count}} přehrání",
|
||||
"song_one": "Píseň",
|
||||
"song_few": "písničky",
|
||||
"song_other": "Písní",
|
||||
"song_one": "Skladba",
|
||||
"song_few": "Skladby",
|
||||
"song_other": "Skladby",
|
||||
"radioStation_one": "Stanice rádia",
|
||||
"radioStation_few": "Stanice rádia",
|
||||
"radioStation_other": "Stanice rádia",
|
||||
@@ -1272,10 +1273,10 @@
|
||||
"startsWith": "Začíná na"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "Min.",
|
||||
"secondShort": "S",
|
||||
"hourShort": "H.",
|
||||
"dayShort": "D."
|
||||
"minuteShort": "min.",
|
||||
"secondShort": "s",
|
||||
"hourShort": "h.",
|
||||
"dayShort": "d."
|
||||
},
|
||||
"visualizer": {
|
||||
"visualizerType": "Typ vizualizéru",
|
||||
|
||||
@@ -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",
|
||||
@@ -1093,7 +1094,7 @@
|
||||
"sidePlayQueueLayout_description": "Sets the layout of the attached side play queue",
|
||||
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "Vertical",
|
||||
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen",
|
||||
"mediaSession_description": "Enables media session integration, displaying media controls and metadata in the system volume overlay and lock screen. Requires the Web Audio Player.",
|
||||
"mediaSession": "Enable media session",
|
||||
"sidePlayQueueStyle": "Side play queue style",
|
||||
"skipDuration_description": "Sets the duration to skip when using the skip buttons on the player bar",
|
||||
|
||||
@@ -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",
|
||||
@@ -296,7 +297,7 @@
|
||||
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
|
||||
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
|
||||
"mediaSession": "Activar sesión de medios",
|
||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
|
||||
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo. Requiere el Reproductor Web de Audio.",
|
||||
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
|
||||
"exportImportSettings_control_exportText": "Exportar configuración",
|
||||
"exportImportSettings_control_importText": "Importar configuración",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 l’icô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
@@ -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
@@ -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": "사용자 정의 태그"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+27
-26
@@ -80,20 +80,20 @@
|
||||
"cancel": "Anuluj",
|
||||
"forceRestartRequired": "Zrestartuj aby zastosować zmiany... Zamknij powiadomienie aby zrestartować",
|
||||
"setting_one": "Ustawienie",
|
||||
"setting_few": "ustawienia",
|
||||
"setting_many": "ustawień",
|
||||
"setting_few": "Ustawienia",
|
||||
"setting_many": "Ustawień",
|
||||
"version": "Wersja",
|
||||
"title": "Tytuł",
|
||||
"filter_one": "Filtr",
|
||||
"filter_few": "filtry",
|
||||
"filter_many": "filtrów",
|
||||
"filter_few": "Filtry",
|
||||
"filter_many": "Filtrów",
|
||||
"filters": "Filtry",
|
||||
"create": "Stwórz",
|
||||
"bitrate": "Bitrate",
|
||||
"saveAndReplace": "Zapisz i zamień",
|
||||
"action_one": "Akcja",
|
||||
"action_few": "akcje",
|
||||
"action_many": "akcji",
|
||||
"action_few": "Akcje",
|
||||
"action_many": "Akcji",
|
||||
"playerMustBePaused": "Odtwarzacz musi być zapauzowany",
|
||||
"confirm": "Potwierdź",
|
||||
"resetToDefault": "Przywróć do domyślnych",
|
||||
@@ -101,8 +101,8 @@
|
||||
"comingSoon": "Już wkrótce…",
|
||||
"reset": "Zresetuj",
|
||||
"channel_one": "Kanał",
|
||||
"channel_few": "kanałów",
|
||||
"channel_many": "kanałów",
|
||||
"channel_few": "Kanałów",
|
||||
"channel_many": "Kanałów",
|
||||
"disable": "Wyłącz",
|
||||
"sortOrder": "Kolejność",
|
||||
"none": "Żaden",
|
||||
@@ -178,17 +178,17 @@
|
||||
},
|
||||
"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",
|
||||
"artist_few": "Wykonawców",
|
||||
"artist_many": "Wykonawców",
|
||||
"folderWithCount_one": "{{count}} katalog",
|
||||
"folderWithCount_few": "{{count}} katalogi",
|
||||
"folderWithCount_many": "{{count}} katalogó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",
|
||||
@@ -227,8 +227,8 @@
|
||||
"play_few": "{{count}} odtworzenia",
|
||||
"play_many": "{{count}} odtworzeń",
|
||||
"song_one": "Piosenka",
|
||||
"song_few": "piosenki",
|
||||
"song_many": "piosenek",
|
||||
"song_few": "Piosenki",
|
||||
"song_many": "Piosenek",
|
||||
"radioStation_one": "Stacja radiowa",
|
||||
"radioStation_few": "Stacje radiowe",
|
||||
"radioStation_many": "Stacji radiowych",
|
||||
@@ -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",
|
||||
@@ -979,7 +980,7 @@
|
||||
"preservePitch": "Utrzymuj ton",
|
||||
"preventSleepOnPlayback_description": "Powstrzymuje ekran przed uśpieniem, gdy muzyka jest odtwarzana",
|
||||
"preventSleepOnPlayback": "Powstrzymuj uśpienie podczas odtwarzania",
|
||||
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokady",
|
||||
"mediaSession_description": "Włącza integrację z Media Session, wyświetlając sterowanie mediami i metadane w systemowym oknie zmiany głośności i na ekranie blokad. Wymaga odtwarzacza web audio.",
|
||||
"mediaSession": "Włącz media session",
|
||||
"transcode": "Włącz transkodowanie",
|
||||
"queryBuilder": "Kreator zaptań",
|
||||
|
||||
+172
-9
@@ -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": "Перемести файл сюда"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"action": {
|
||||
"addToFavorites": "Idagdag sa $t(entity.favorite, {\"count\": 2})",
|
||||
"addToPlaylist": "Idagdag sa $t(entity.playlist, {\"count\": 1})",
|
||||
"addOrRemoveFromSelection": "Idagdag o alisin sa pinili",
|
||||
"collapseAllFolders": "Isara lahat ng mga folder",
|
||||
"expandAllFolders": "Buksan lahat ng mga folder",
|
||||
"createPlaylist": "Gumawa $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "Gumawa $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "Piliin lahat",
|
||||
"deselectAll": "Huwag piliin lahat",
|
||||
"downloadStarted": "Nagsimulang mag-dowload ng {{count}} (mga) aytem"
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,8 @@
|
||||
"tableColumns": "Стовпці таблиці",
|
||||
"itemsMore": "{{count}} більше",
|
||||
"numberOfResults": "{{numberOfResults}} результатів",
|
||||
"newVersionAvailable": "Доступна нова версія"
|
||||
"newVersionAvailable": "Доступна нова версія",
|
||||
"back": "Повернутися"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Альбом",
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
"bitrate": "位元率",
|
||||
"bpm": "BPM",
|
||||
"clear": "清空",
|
||||
"collapse": "折疊",
|
||||
"collapse": "摺疊",
|
||||
"comingSoon": "即將推出…",
|
||||
"confirm": "確認",
|
||||
"decrease": "降低",
|
||||
"delete": "刪除",
|
||||
"descending": "降冪",
|
||||
"description": "描述",
|
||||
"forceRestartRequired": "重新啟動應用程式以使更改生效…關閉通知後即可重啟",
|
||||
"forceRestartRequired": "重啟以套用變更… 關閉通知後即可重啟",
|
||||
"menu": "選單",
|
||||
"action_other": "操作",
|
||||
"add": "新增",
|
||||
"areYouSure": "你確定嗎?",
|
||||
"ascending": "升冪",
|
||||
"disable": "禁用",
|
||||
"disable": "停用",
|
||||
"disc": "光碟",
|
||||
"dismiss": "不再顯示",
|
||||
"dismiss": "不理會",
|
||||
"duration": "時長",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
@@ -31,7 +31,7 @@
|
||||
"forward": "前進",
|
||||
"gap": "空隙",
|
||||
"home": "首頁",
|
||||
"increase": "增高",
|
||||
"increase": "提升",
|
||||
"left": "左",
|
||||
"limit": "限制",
|
||||
"manage": "管理",
|
||||
@@ -46,8 +46,8 @@
|
||||
"rating": "評分",
|
||||
"refresh": "重新整理",
|
||||
"reset": "重置",
|
||||
"resetToDefault": "恢復為預設值",
|
||||
"restartRequired": "需要重新啟動應用程式",
|
||||
"resetToDefault": "重置為預設",
|
||||
"restartRequired": "需要重新啟動",
|
||||
"right": "右",
|
||||
"save": "儲存",
|
||||
"saveAndReplace": "儲存並取代",
|
||||
@@ -55,7 +55,7 @@
|
||||
"search": "搜尋",
|
||||
"sortOrder": "順序",
|
||||
"title": "標題",
|
||||
"trackNumber": "音軌編號",
|
||||
"trackNumber": "曲目",
|
||||
"unknown": "未知",
|
||||
"size": "大小",
|
||||
"version": "版本",
|
||||
@@ -64,24 +64,24 @@
|
||||
"cancel": "取消",
|
||||
"center": "中央",
|
||||
"channel_other": "聲道",
|
||||
"configure": "設定",
|
||||
"configure": "配置",
|
||||
"create": "建立",
|
||||
"currentSong": "目前$t(entity.track, {\"count\": 1})",
|
||||
"currentSong": "當前 $t(entity.track, {\"count\": 1})",
|
||||
"minimize": "最小化",
|
||||
"modified": "已修改",
|
||||
"name": "名稱",
|
||||
"no": "否",
|
||||
"none": "無",
|
||||
"noResultsFromQuery": "未查詢到匹配結果",
|
||||
"noResultsFromQuery": "查詢回傳了無結果",
|
||||
"note": "注釋",
|
||||
"additionalParticipants": "額外參與者",
|
||||
"newVersion": "已安裝新版本 ({{version}})",
|
||||
"newVersion": "新版本 ({{version}}) 已被安裝",
|
||||
"viewReleaseNotes": "查看發行註記",
|
||||
"albumGain": "專輯增益",
|
||||
"albumPeak": "專輯峰值",
|
||||
"bitDepth": "位元深度",
|
||||
"close": "關閉",
|
||||
"codec": "編碼",
|
||||
"codec": "編解碼器",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"preview": "預覽",
|
||||
"reload": "重新載入",
|
||||
@@ -105,8 +105,8 @@
|
||||
"clean": "清除",
|
||||
"explicitStatus": "露骨狀態",
|
||||
"explicit": "露骨",
|
||||
"gridRows": "網格行",
|
||||
"noFilters": "未設定任何過濾器",
|
||||
"gridRows": "網格列",
|
||||
"noFilters": "未配置篩選器",
|
||||
"countSelected": "{{count}} 個已選取",
|
||||
"retry": "重試",
|
||||
"example": "範例",
|
||||
@@ -116,45 +116,45 @@
|
||||
"itemsMore": "{{count}} 更多",
|
||||
"filter_single": "單選",
|
||||
"filter_multiple": "複選",
|
||||
"newVersionAvailable": "有新的版本可供使用",
|
||||
"newVersionAvailable": "有新版本可用",
|
||||
"numberOfResults": "{{numberOfResults}} 項結果",
|
||||
"grouping": "分組",
|
||||
"back": "返回",
|
||||
"openFolder": "開啟資料夾"
|
||||
},
|
||||
"error": {
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
|
||||
"apiRouteError": "請求失敗:無法路由",
|
||||
"audioDeviceFetchError": "無法取得音訊設備",
|
||||
"endpointNotImplementedError": "{{serverType}} 尚未實作端點 {{endpoint}}",
|
||||
"apiRouteError": "無法路由請求",
|
||||
"audioDeviceFetchError": "嘗試取得音訊裝置時發生了錯誤",
|
||||
"authenticationFailed": "驗證失敗",
|
||||
"credentialsRequired": "需要憑證",
|
||||
"genericError": "發生了錯誤",
|
||||
"invalidServer": "無效的伺服器",
|
||||
"localFontAccessDenied": "無法取得本地字型",
|
||||
"localFontAccessDenied": "存取本地字型被拒絕",
|
||||
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||
"remotePortError": "設定遠端伺服器連接埠時發生錯誤",
|
||||
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||
"remoteDisableError": "嘗試 $t(common.disable) 遠端伺服器時發生了錯誤",
|
||||
"remoteEnableError": "嘗試 $t(common.enable) 遠端伺服器時發生了錯誤",
|
||||
"remotePortError": "嘗試設定遠端伺服器連接埠時發生了錯誤",
|
||||
"remotePortWarning": "重啟伺服器以套用新連接埠",
|
||||
"serverRequired": "需要伺服器",
|
||||
"sessionExpiredError": "工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字型時出現錯誤",
|
||||
"sessionExpiredError": "您的工作階段已過期",
|
||||
"systemFontError": "嘗試取得系統字型時發生了錯誤",
|
||||
"serverNotSelectedError": "未選擇伺服器",
|
||||
"mpvRequired": "需要 MPV",
|
||||
"playbackError": "無法播放媒體",
|
||||
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅將資料夾中的曲目分組",
|
||||
"playbackError": "嘗試播放媒體時發生了錯誤",
|
||||
"badAlbum": "您看到此頁面是因為這首歌不是專輯的一部分。如果您的音樂資料夾頂層有一首歌,則很可能會看到此問題。 Jellyfin 僅會將資料夾中的曲目分組",
|
||||
"badValue": "無效選項「{{value}}」。該值不再存在",
|
||||
"networkError": "發生網路錯誤",
|
||||
"notificationDenied": "通知權限被拒絕。此設定無效",
|
||||
"networkError": "發生了網路錯誤",
|
||||
"notificationDenied": "通知權限被拒絕。此設定無影響",
|
||||
"openError": "無法開啟檔案",
|
||||
"multipleServerSaveQueueError": "播放佇列中包含不是來自目前伺服器的歌曲,此操作不受支援",
|
||||
"multipleServerSaveQueueError": "播放佇列中包含了並非來自當前伺服器的歌曲。此操作不受支援",
|
||||
"saveQueueFailed": "儲存播放佇列失敗",
|
||||
"settingsSyncError": "偵測到渲染器與主程式之間的設定不一致,請重新啟動應用程式以套用變更",
|
||||
"noNetwork": "伺服器無法連線",
|
||||
"noNetworkDescription": "無法連接到此伺服器",
|
||||
"settingsSyncError": "偵測到渲染器與主程式之間的設定不一致。請重新啟動應用程式以套用變更",
|
||||
"noNetwork": "伺服器不可用",
|
||||
"noNetworkDescription": "無法連線至此伺服器",
|
||||
"invalidJson": "無效的 JSON",
|
||||
"serverLockSingleServer": "當伺服器鎖定時只允許一個伺服器",
|
||||
"playbackPausedDueToError": "發生錯誤,已停止播放"
|
||||
"playbackPausedDueToError": "播放因錯誤而暫停"
|
||||
},
|
||||
"page": {
|
||||
"contextMenu": {
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"appMenu": {
|
||||
"openBrowserDevtools": "開啟瀏覽器開發者工具",
|
||||
"collapseSidebar": "折疊側邊欄",
|
||||
"collapseSidebar": "摺疊側邊欄",
|
||||
"expandSidebar": "展開側邊欄",
|
||||
"goBack": "返回",
|
||||
"goForward": "前進",
|
||||
@@ -269,7 +269,7 @@
|
||||
"transcoding": "轉碼",
|
||||
"discord": "Discord",
|
||||
"queryBuilder": "查詢建構器",
|
||||
"playerFilters": "播放過濾器",
|
||||
"playerFilters": "播放篩選器",
|
||||
"logger": "日誌記錄器",
|
||||
"lyricsDisplay": "歌詞顯示"
|
||||
},
|
||||
@@ -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": "應用程式將使用深色主題",
|
||||
@@ -666,7 +667,7 @@
|
||||
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
|
||||
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
|
||||
"mediaSession": "啟用 Media Session",
|
||||
"mediaSession_description": "啟用 Media Session 整合功能,於系統音量 Overlay 和鎖定畫面中顯示媒體資料與控制面板",
|
||||
"mediaSession_description": "啟用 Media Session 整合功能,在系統音量疊加層和鎖定畫面上顯示媒體控制項與中繼資料。此功能需要使用網頁播放器。",
|
||||
"releaseChannel": "發佈通道",
|
||||
"analyticsDisable": "選擇退出使用情況分析",
|
||||
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
|
||||
@@ -712,7 +713,7 @@
|
||||
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
|
||||
"followCurrentSong": "跟隨當前歌曲",
|
||||
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
|
||||
"playerFilters": "從佇列中過濾歌曲",
|
||||
"playerFilters": "從佇列中篩選歌曲",
|
||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||
"autoDJ": "Auto DJ",
|
||||
"autoDJ_itemCount": "項目數量",
|
||||
@@ -763,7 +764,7 @@
|
||||
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
|
||||
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
|
||||
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
|
||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||
"sidebarPlaylistListFilterRegex": "播放清單篩選器正規表達式",
|
||||
"blurExplicitImages": "模糊露骨圖片",
|
||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
|
||||
@@ -887,7 +888,7 @@
|
||||
"size": "$t(common.size)",
|
||||
"title": "$t(common.title)",
|
||||
"titleCombined": "$t(common.title)(合併)",
|
||||
"trackNumber": "曲目編號",
|
||||
"trackNumber": "曲目",
|
||||
"year": "$t(common.year)",
|
||||
"rating": "$t(common.rating)",
|
||||
"codec": "$t(common.codec)",
|
||||
@@ -930,20 +931,20 @@
|
||||
"bpm": "BPM",
|
||||
"songCount": "曲目",
|
||||
"title": "標題",
|
||||
"trackNumber": "曲目編號",
|
||||
"trackNumber": "曲目",
|
||||
"size": "大小",
|
||||
"codec": "編碼",
|
||||
"codec": "編解碼器",
|
||||
"owner": "擁有者",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"addToFavorites": "新增到$t(entity.favorite, {\"count\": 2})",
|
||||
"clearQueue": "清空播放佇列",
|
||||
"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})",
|
||||
"addToPlaylist": "新增至 $t(entity.playlist, {\"count\": 1})",
|
||||
"deselectAll": "取消全選",
|
||||
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "前往頁面",
|
||||
@@ -952,13 +953,13 @@
|
||||
"refresh": "$t(common.refresh)",
|
||||
"removeFromFavorites": "從 $t(entity.favorite, {\"count\": 2}) 移除",
|
||||
"removeFromPlaylist": "從 $t(entity.playlist, {\"count\": 1}) 移除",
|
||||
"removeFromQueue": "從播放佇列中移除",
|
||||
"removeFromQueue": "從佇列中移除",
|
||||
"setRating": "評分",
|
||||
"toggleSmartPlaylistEditor": "切換 $t(entity.smartPlaylist) 編輯器",
|
||||
"viewPlaylists": "查看 $t(entity.playlist, {\"count\": 2})",
|
||||
"moveToNext": "移至下一項",
|
||||
"openIn": {
|
||||
"lastfm": "在Last.fm開啟",
|
||||
"lastfm": "在 Last.fm 中開啟",
|
||||
"musicbrainz": "在 MusicBrainz 開啟",
|
||||
"spotify": "在 Spotify 中開啟",
|
||||
"listenbrainz": "在 ListenBrainz 中開啟",
|
||||
@@ -999,7 +1000,7 @@
|
||||
"playlistWithCount_other": "{{count}} 個播放清單",
|
||||
"smartPlaylist": "智慧 $t(entity.playlist, {\"count\": 1})",
|
||||
"track_other": "曲目",
|
||||
"trackWithCount_other": "{{count}} 首曲目",
|
||||
"trackWithCount_other": "{{count}} 個曲目",
|
||||
"albumWithCount_other": "{{count}} 張專輯",
|
||||
"play_other": "{{count}} 次播放",
|
||||
"song_other": "歌曲",
|
||||
@@ -1009,7 +1010,7 @@
|
||||
"filter": {
|
||||
"albumCount": "$t(entity.album, {\"count\": 2}) 數",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"biography": "個人簡介",
|
||||
"biography": "簡介",
|
||||
"bitrate": "位元率",
|
||||
"bpm": "BPM",
|
||||
"channels": "$t(common.channel, {\"count\": 2})",
|
||||
@@ -1022,14 +1023,14 @@
|
||||
"id": "ID",
|
||||
"fromYear": "從年份",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"isCompilation": "為合輯",
|
||||
"isFavorited": "已收藏",
|
||||
"isPublic": "已公開",
|
||||
"isRated": "已評分",
|
||||
"isCompilation": "是否為合輯",
|
||||
"isFavorited": "是否為收藏",
|
||||
"isPublic": "是否為公開",
|
||||
"isRated": "是否已評分",
|
||||
"name": "名稱",
|
||||
"note": "注釋",
|
||||
"isRecentlyPlayed": "最近播放過",
|
||||
"lastPlayed": "上次播放過",
|
||||
"isRecentlyPlayed": "是否最近播放過",
|
||||
"lastPlayed": "上次播放",
|
||||
"mostPlayed": "播放最多",
|
||||
"owner": "$t(common.owner)",
|
||||
"path": "路徑",
|
||||
@@ -1047,7 +1048,7 @@
|
||||
"releaseYear": "發行年份",
|
||||
"search": "搜尋",
|
||||
"title": "標題",
|
||||
"toYear": "從年份",
|
||||
"toYear": "至年份",
|
||||
"trackNumber": "曲目",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"sortName": "排序名稱",
|
||||
@@ -1101,7 +1102,7 @@
|
||||
"title": "查詢編輯器",
|
||||
"addRuleGroup": "新增規則群組",
|
||||
"removeRuleGroup": "移除規則群組",
|
||||
"resetToDefault": "恢復為預設值",
|
||||
"resetToDefault": "重置為預設",
|
||||
"clearFilters": "清除篩選"
|
||||
},
|
||||
"updateServer": {
|
||||
@@ -1142,8 +1143,8 @@
|
||||
"input_genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"input_limit": "多少曲目?",
|
||||
"input_minYear": "起始年份",
|
||||
"input_maxYear": "結束年份",
|
||||
"input_played": "播放過濾器",
|
||||
"input_maxYear": "至年份",
|
||||
"input_played": "播放篩選器",
|
||||
"input_played_optionAll": "所有曲目",
|
||||
"input_played_optionUnplayed": "僅未播放的曲目",
|
||||
"input_played_optionPlayed": "僅播放過的曲目",
|
||||
@@ -1380,7 +1381,7 @@
|
||||
}
|
||||
},
|
||||
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何曲目。確保在提示時啟用音訊擷取。",
|
||||
"systemAudioConsentAllow": "允許",
|
||||
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
|
||||
"systemAudioConsentDecline": "拒絕",
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -1039,7 +1088,7 @@ if (!singleInstance) {
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
"script-src 'self' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
|
||||
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@ export const utils = {
|
||||
rendererToggleSidebar,
|
||||
rendererUpdateAvailable,
|
||||
saveCustomCss,
|
||||
separator: isWindows() ? '\\' : '/',
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
stopPowerSaveBlocker,
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -366,7 +366,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||
? query.id
|
||||
: undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
id:
|
||||
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
|
||||
? query.id
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -419,7 +422,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
|
||||
? query.id
|
||||
: undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
id:
|
||||
query.type === LibraryItem.SONG || query.type === LibraryItem.PLAYLIST_SONG
|
||||
? query.id
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2303,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,
|
||||
@@ -2315,40 +2321,55 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
|
||||
if (query.submission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
|
||||
|
||||
switch (query.event) {
|
||||
case 'pause':
|
||||
state = 'paused';
|
||||
break;
|
||||
case 'start':
|
||||
state = 'starting';
|
||||
break;
|
||||
case 'timeupdate':
|
||||
case 'unpause':
|
||||
state = 'playing';
|
||||
break;
|
||||
default:
|
||||
state = 'playing';
|
||||
}
|
||||
|
||||
const res = await ssApiClient(apiClientProps).reportPlayback({
|
||||
query: {
|
||||
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':
|
||||
promises.push(reportPlayback('paused'));
|
||||
break;
|
||||
case 'start':
|
||||
promises.push(reportPlayback('starting'));
|
||||
promises.push(reportPlayback('playing'));
|
||||
break;
|
||||
case 'stop':
|
||||
promises.push(reportPlayback('stopped'));
|
||||
break;
|
||||
case 'unpause':
|
||||
promises.push(reportPlayback('playing'));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
for (const promise of promises) {
|
||||
const res = await promise;
|
||||
|
||||
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({
|
||||
|
||||
+2
@@ -58,6 +58,8 @@ a.title {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
--text-text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
|
||||
@@ -4,50 +4,38 @@ import { generatePath, useNavigate } from 'react-router';
|
||||
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Album, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface GoToActionProps {
|
||||
items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];
|
||||
items: Album[] | QueueSong[] | Song[];
|
||||
}
|
||||
|
||||
export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { albumArtists, albumId } = useMemo(() => {
|
||||
const { albumId, artists } = useMemo(() => {
|
||||
const firstItem = items[0];
|
||||
|
||||
if (firstItem._itemType === LibraryItem.ALBUM) {
|
||||
switch (firstItem._itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
return {
|
||||
albumArtists: firstItem.albumArtists || [],
|
||||
albumId: firstItem.id,
|
||||
artists: firstItem.albumArtists || [],
|
||||
};
|
||||
} else if (firstItem._itemType === LibraryItem.SONG) {
|
||||
case LibraryItem.SONG:
|
||||
return {
|
||||
albumArtists: firstItem.albumArtists || [],
|
||||
albumId: firstItem.albumId,
|
||||
artists:
|
||||
(firstItem.artists?.length ? firstItem.artists : firstItem.albumArtists) ||
|
||||
[],
|
||||
};
|
||||
} else if (
|
||||
firstItem._itemType === LibraryItem.ARTIST ||
|
||||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
|
||||
) {
|
||||
default:
|
||||
return {
|
||||
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
|
||||
albumId: null,
|
||||
artists: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: [],
|
||||
albumId: null,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const handleGoToAlbum = useCallback(() => {
|
||||
@@ -55,7 +43,7 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
|
||||
}, [albumId, navigate]);
|
||||
|
||||
const handleGoToAlbumArtist = useCallback(
|
||||
const handleGoToArtist = useCallback(
|
||||
(albumArtistId: string) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
|
||||
},
|
||||
@@ -81,13 +69,13 @@ export const GoToAction = ({ items }: GoToActionProps) => {
|
||||
{t('page.contextMenu.goToAlbum')}
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{albumArtists.map((albumArtist) => (
|
||||
{artists.map((artist) => (
|
||||
<ContextMenu.Item
|
||||
key={albumArtist.id}
|
||||
key={artist.id}
|
||||
leftIcon="artist"
|
||||
onSelect={() => handleGoToAlbumArtist(albumArtist.id)}
|
||||
onSelect={() => handleGoToArtist(artist.id)}
|
||||
>
|
||||
{`${t('page.contextMenu.goTo')} ${albumArtist.name}`}
|
||||
{`${t('page.contextMenu.goTo')} ${artist.name}`}
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.SubmenuContent>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
|
||||
@@ -39,8 +38,6 @@ export const AlbumArtistContextMenu = ({ items, type }: AlbumArtistContextMenuPr
|
||||
<DownloadAction ids={ids} />
|
||||
<ShareAction ids={ids} itemType={LibraryItem.ALBUM_ARTIST} />
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
import { PlayArtistRadioAction } from '/@/renderer/features/context-menu/actions/play-artist-radio-action';
|
||||
import { SetFavoriteAction } from '/@/renderer/features/context-menu/actions/set-favorite-action';
|
||||
@@ -39,8 +38,6 @@ export const ArtistContextMenu = ({ items, type }: ArtistContextMenuProps) => {
|
||||
<DownloadAction ids={ids} />
|
||||
<ShareAction ids={ids} itemType={LibraryItem.ARTIST} />
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
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;
|
||||
}
|
||||
|
||||
if (mode === 'endOfSong') {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}, [active, mode]);
|
||||
}
|
||||
|
||||
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" />
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
let startupRestoreSessionHandled = false;
|
||||
|
||||
export const useQueueRestoreTimestamp = () => {
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
|
||||
@@ -51,28 +53,65 @@ export const useInitialTimestampRestore = () => {
|
||||
|
||||
const startupRestoreInitializedRef = useRef(false);
|
||||
const startupSeekArmedRef = useRef<null | number>(null);
|
||||
const startupSeekTargetUniqueIdRef = useRef<null | string>(null);
|
||||
const startupSeekAppliedRef = useRef(false);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
const cancelStartupSeek = useCallback(() => {
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
startupSeekTargetUniqueIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyStartupSeek = useCallback(() => {
|
||||
const seekTimestamp = startupSeekArmedRef.current;
|
||||
|
||||
if (startupSeekAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!seekTimestamp || seekTimestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
|
||||
const currentUniqueId = usePlayerStore.getState().getQueue().items[
|
||||
usePlayerStore.getState().player.index
|
||||
]?._uniqueId;
|
||||
|
||||
if (targetUniqueId && currentUniqueId !== targetUniqueId) {
|
||||
cancelStartupSeek();
|
||||
return;
|
||||
}
|
||||
|
||||
startupSeekAppliedRef.current = true;
|
||||
startupSeekArmedRef.current = null;
|
||||
startupSeekTargetUniqueIdRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
mediaSeekToTimestamp(seekTimestamp);
|
||||
}, 100);
|
||||
}, [mediaSeekToTimestamp]);
|
||||
}, [cancelStartupSeek, mediaSeekToTimestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current) {
|
||||
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
|
||||
if (
|
||||
!targetUniqueId ||
|
||||
startupSeekAppliedRef.current ||
|
||||
!currentSong ||
|
||||
currentSong._uniqueId === targetUniqueId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelStartupSeek();
|
||||
}, [cancelStartupSeek, currentSong]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupRestoreInitializedRef.current || startupRestoreSessionHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,9 +120,11 @@ export const useInitialTimestampRestore = () => {
|
||||
}
|
||||
|
||||
startupRestoreInitializedRef.current = true;
|
||||
startupRestoreSessionHandled = true;
|
||||
|
||||
if (timestamp > 0) {
|
||||
startupSeekArmedRef.current = timestamp;
|
||||
startupSeekTargetUniqueIdRef.current = currentSong._uniqueId;
|
||||
}
|
||||
|
||||
if (playerStatus === PlayerStatus.PLAYING) {
|
||||
@@ -129,8 +170,7 @@ export const useSaveQueue = () => {
|
||||
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.controller.savePlayQueue({
|
||||
return api.controller.savePlayQueue({
|
||||
apiClientProps: { serverId },
|
||||
query: {
|
||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||
@@ -138,17 +178,12 @@ export const useSaveQueue = () => {
|
||||
songs: queue.items.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
|
||||
toast.success({
|
||||
message: t('form.saveQueue.success'),
|
||||
});
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.saveQueueFailed'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const getPositionValue = (seconds: number, useTicks: boolean) => {
|
||||
return Math.round(seconds * 1e7);
|
||||
}
|
||||
|
||||
return seconds;
|
||||
return seconds * 1000;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -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();
|
||||
},
|
||||
[
|
||||
@@ -459,12 +497,14 @@ export const useScrobble = () => {
|
||||
lastProgressEventRef.current = properties.timestamp;
|
||||
lastSeekEventRef.current = now;
|
||||
|
||||
const currentStatus = usePlayerStore.getState().player.status;
|
||||
|
||||
sendScrobble.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: currentSong._serverId || '' },
|
||||
query: {
|
||||
albumId: currentSong.albumId,
|
||||
event: 'timeupdate',
|
||||
event: currentStatus === PlayerStatus.PLAYING ? 'unpause' : 'pause',
|
||||
id: currentSong.id,
|
||||
mediaType: mediaType,
|
||||
playbackRate: playbackRate,
|
||||
@@ -589,6 +629,7 @@ export const useScrobble = () => {
|
||||
isCurrentSongScrobbledRef.current = false;
|
||||
lastProgressEventRef.current = 0;
|
||||
previousTimestampRef.current = 0;
|
||||
stopPositionRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
lastListenSampleTimeRef.current = null;
|
||||
|
||||
@@ -623,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
|
||||
|
||||
@@ -189,7 +189,7 @@ const appRouterModals = {
|
||||
|
||||
export const AppRouter = () => {
|
||||
const router = (
|
||||
<HashRouter unstable_useTransitions={false}>
|
||||
<HashRouter>
|
||||
<ModalsProvider modals={appRouterModals}>
|
||||
<RouterErrorBoundary>
|
||||
<Routes>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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' | 'timeupdate' | 'unpause';
|
||||
event?: 'pause' | 'start' | 'stop' | 'unpause';
|
||||
id: string;
|
||||
mediaType: 'podcast' | 'song';
|
||||
playbackRate: number;
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { HotkeyItem } from '@mantine/hooks';
|
||||
|
||||
const RESERVED_KEYS = new Set(['alt', 'ctrl', 'meta', 'mod', 'shift']);
|
||||
|
||||
const PUNCTUATION_KEY_TO_PHYSICAL: Record<string, string> = {
|
||||
"'": 'Quote',
|
||||
',': 'Comma',
|
||||
'-': 'Minus',
|
||||
'.': 'Period',
|
||||
'/': 'Slash',
|
||||
';': 'Semicolon',
|
||||
'=': 'Equal',
|
||||
'[': 'BracketLeft',
|
||||
'\\': 'Backslash',
|
||||
']': 'BracketRight',
|
||||
'`': 'Backquote',
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts stored hotkey strings to Mantine's physical-key format.
|
||||
* Mantine matches KeyboardEvent.code via normalizeKey, which turns Digit1 into
|
||||
@@ -25,6 +39,11 @@ export const toPhysicalHotkey = (hotkey: string): string =>
|
||||
return `Digit${part}`;
|
||||
}
|
||||
|
||||
const punctuationPhysical = PUNCTUATION_KEY_TO_PHYSICAL[part];
|
||||
if (punctuationPhysical) {
|
||||
return punctuationPhysical;
|
||||
}
|
||||
|
||||
return part;
|
||||
})
|
||||
.join('+');
|
||||
@@ -33,5 +52,5 @@ export const withPhysicalKeys = (hotkeys: HotkeyItem[]): HotkeyItem[] =>
|
||||
hotkeys.map(([hotkey, handler, options]) => [
|
||||
toPhysicalHotkey(hotkey),
|
||||
handler,
|
||||
{ ...options, usePhysicalKeys: true },
|
||||
{ ...options, preventDefault: true, usePhysicalKeys: true },
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,12 @@ const CODE_TO_HOTKEY_KEY: Record<string, string> = {
|
||||
ArrowLeft: 'arrowleft',
|
||||
ArrowRight: 'arrowright',
|
||||
ArrowUp: 'arrowup',
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
Backspace: 'backspace',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Comma: ',',
|
||||
Delete: 'delete',
|
||||
End: 'end',
|
||||
Enter: 'enter',
|
||||
@@ -14,6 +19,10 @@ const CODE_TO_HOTKEY_KEY: Record<string, string> = {
|
||||
Minus: 'minus',
|
||||
PageDown: 'pagedown',
|
||||
PageUp: 'pageup',
|
||||
Period: '.',
|
||||
Quote: "'",
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
Space: 'space',
|
||||
Tab: 'tab',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user