Compare commits

...

56 Commits

Author SHA1 Message Date
Hosted Weblate f1f415daa8 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

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

Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-10 22:25:59 +02:00
jeffvli 1ee767352a add 1 week minimumReleaseAge for packages 2026-06-10 10:15:46 -07:00
jeffvli 66a123c10d remove pnpm10 compatibility in package.json 2026-06-10 10:15:46 -07:00
Hosted Weblate de0ddfe226 Translated using Weblate
Currently translated at 100.0% (1245 of 1245 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: 為什麼不加空格 <c++23@users.noreply.hosted.weblate.org>
2026-06-10 08:01:28 +00:00
oHaoDaio d23f7619ec chore: Support pnpm v11 2026-06-09 22:15:47 -07:00
Kendall Garner 44de6f2207 chore: upgrade depdencencies (#2133)
* upgrade depdencencies
2026-06-09 21:18:47 -07:00
BotBlake f6f25154a1 add "enhancement" to exempt-issue-labels (#2061) 2026-06-09 11:22:36 -07:00
Pedro Vieira 880516069d fix: preserve infinite list cache on component remount (fixes random sort reshuffling) (#2097)
* fix: preserve infinite list cache on component remount

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

Translated using Weblate

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

Translated using Weblate

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

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

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

Translated using Weblate

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

Translated using Weblate

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

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

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

Translated using Weblate

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

Translated using Weblate

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

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

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

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

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

* fix(player): unify seek comments per review

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

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

---------

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

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

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

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

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

* make the linter happy
2026-06-02 19:34:16 +00:00
Hosted Weblate 4b4d64c7fc Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 36.9% (460 of 1244 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Translated using Weblate

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

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: albatrays <weblate.duct925@passmail.net>
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-02 16:01:13 +02:00
Hosted Weblate f7e1198482 Added translation using Weblate (Estonian)
Co-authored-by: rimasx <riks_12@hot.ee>
2026-06-01 15:15:06 +02:00
jeffvli 7243ed7f15 fix timestamp restore when song switched before playback start (#2094) 2026-05-31 23:09:33 -07:00
York 7e9a78898f fix: punctuation hotkeys not captured (#2091) 2026-05-31 22:43:58 -07:00
Kendall Garner 6aab8d4121 fix(scrobble): use proper pause/unpause for seek, use ms for subsonic 2026-05-31 21:13:11 -07:00
Kendall Garner 70594a696b chore(context menu): show go to only for albums/tracks, prefer artist over album artist where possible 2026-05-31 19:44:58 -07:00
York 08b4c620f2 Merge pull request #2082 from york9675/fix/spacebar
Fix playback hotkeys by preventing browser default
2026-05-31 14:36:53 +00:00
Hosted Weblate 7a20cf3853 Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-05-31 10:51:08 +02:00
Hosted Weblate dd186c570f Translated using Weblate
Currently translated at 0.8% (10 of 1244 strings) (Tagalog)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/tl/

Co-authored-by: Redd <reddtheeditor@gmail.com>
2026-05-31 08:04:07 +02:00
Hosted Weblate d5e9d491b6 Added translation using Weblate (Tagalog)
Co-authored-by: Redd <reddtheeditor@gmail.com>
2026-05-31 06:08:43 +02:00
Hosted Weblate 28dc822e4f Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 84.1% (1047 of 1244 strings) (English)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Lucas Winther <lucasw89@live.dk>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-30 19:01:12 +00:00
Kendall Garner def1b1e710 fix(butterchurn): allow unsafe wasm for butterchurn 2026-05-30 07:01:53 -07:00
Kendall Garner 2ff9e4b0a2 fix(subsonic): fix favoriting/unfavoriting on playlist songs 2026-05-29 09:19:39 -07:00
jeffvli 49bfc907cd bump lock-threads to v6 2026-05-28 23:07:47 -07:00
jeffvli 34314bdf46 update to v1.12.1 2026-05-28 02:06:41 -07:00
jeffvli 9d53c53c54 fix queue end handling to prevent repeat 2026-05-28 02:06:07 -07:00
jeffvli 8acd585630 clean up discord rpc implementation with usePlayerEvents 2026-05-28 01:50:29 -07:00
jeffvli 1f5907716f disable interval-based timeupdate scrobbles 2026-05-28 00:59:30 -07:00
jeffvli 99ae0c99c6 fix restart requirement logic when switching windowbar style 2026-05-28 00:46:07 -07:00
jeffvli a56253cd3a fix queue height when using web windowbar style (#2068) 2026-05-28 00:46:06 -07:00
Kendall Garner a2cdce66bc chore(github): update bug report template 2026-05-27 21:20:08 -07:00
Jonathan Grotelüschen 7454832663 fix: set RESOURCES_PATH relative to app.getAppPath() (#2064)
When running the app with system electron, process.resourcesPath points
to the resources folder of the system electron, not the one from
feishin.
2026-05-27 20:01:54 -07:00
Hosted Weblate 1ed185606d Translated using Weblate
Currently translated at 100.0% (1244 of 1244 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

Currently translated at 54.9% (684 of 1244 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 17.8% (222 of 1244 strings) (Arabic)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ar/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Laalo <hyohnoo3@gmail.com>
Co-authored-by: Zarakkas <kaz@users.noreply.hosted.weblate.org>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-27 05:11:44 +00:00
jeffvli d9da588c7c add workaround for identical Jellyfin release name bug (#2041) 2026-05-26 22:06:40 -07:00
jeffvli e206136156 add physical key mapping for useHotkeys to support alt keyboard languages (#2051) 2026-05-26 21:55:08 -07:00
jeffvli 57b11e0dae remove invalid expand button on Playlist table index column 2026-05-26 20:49:19 -07:00
jeffvli 2fc130d709 validate mpv extra parameters to prevent empty string param (#2058) 2026-05-26 20:46:17 -07:00
jeffvli 1aa6b88cfa allow transcode on waveform streamURL (#2060) 2026-05-26 20:35:23 -07:00
jeffvli 329d028edd add open delay to scrobble status HoverCard 2026-05-26 20:05:01 -07:00
jeffvli 4955f30081 fix regression on numeric column designation (#2065) 2026-05-26 19:51:42 -07:00
jeffvli bf7ca937ff force pnpm v10 in dockerfile 2026-05-25 22:00:31 -07:00
84 changed files with 5820 additions and 3265 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: checkboxes
id: check-duplicate
attributes:
label: I have already checked through the existing bug reports and found no duplicates
label: I have already checked through the existing (both open AND closed) bug reports and found no duplicates
options:
- label: 'Yes'
required: true
-4
View File
@@ -44,8 +44,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
@@ -129,8 +127,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-4
View File
@@ -19,8 +19,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
@@ -123,8 +121,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
+9 -5
View File
@@ -4,6 +4,11 @@ permissions: write-all
on:
workflow_dispatch:
inputs:
tag:
description: 'Docker image tag (e.g. 1.12.0 or latest)'
required: true
type: string
push:
tags:
- 'v*.*.*'
@@ -33,11 +38,10 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }}
type=semver,pattern={{major}},enable=${{ github.event_name == 'push' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -38,8 +38,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
-2
View File
@@ -16,8 +16,6 @@ jobs:
- name: Install Node and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
+6 -5
View File
@@ -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
@@ -29,19 +30,19 @@ jobs:
days-before-pr-close: 30
stale-issue-message: >
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>development</code> branch, please reply with all of the information you have about it in order to keep the issue open.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
stale-pr-message: >
This PR has been automatically marked as stale because it has not had recent activity. The resources of the Feishin team are limited, and so we are asking for your help.
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
stale-issue-label: 'stale'
exempt-issue-labels: 'keep,security'
exempt-issue-labels: 'keep,security,enhancement'
stale-pr-label: 'stale'
exempt-pr-labels: 'keep,security'
-2
View File
@@ -12,8 +12,6 @@ jobs:
- name: Install Node.js and PNPM
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install
+1 -3
View File
@@ -3,9 +3,7 @@ FROM node:23-alpine AS builder
WORKDIR /app
# Copy package.json first to cache node_modules
COPY package.json pnpm-lock.yaml .
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
RUN pnpm install
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
publish:
+1 -1
View File
@@ -63,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: '1.0.2'
appimage: '1.0.3'
npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+43 -49
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.12.0",
"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,37 +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",
"esbuild"
]
"vite-plugin-pwa": "^1.3.0"
},
"packageManager": "pnpm@11.5.2",
"productName": "feishin"
}
+2186 -2388
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
allowBuilds:
abstract-socket: true
electron: true
electron-winstaller: true
esbuild: true
minimumReleaseAge: 10080
overrides:
'xml2js': '0.5.0'
'react-router': '7.14.0'
+18
View File
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
import de from './locales/de.json';
import en from './locales/en.json';
import es from './locales/es.json';
import et from './locales/et.json';
import eu from './locales/eu.json';
import fa from './locales/fa.json';
import fi from './locales/fi.json';
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import ta from './locales/ta.json';
import th from './locales/th.json';
import tl from './locales/tl.json';
import tr from './locales/tr.json';
import zhHans from './locales/zh-Hans.json';
import zhHant from './locales/zh-Hant.json';
@@ -38,6 +41,7 @@ const resources = {
de: { translation: de },
en: { translation: en },
es: { translation: es },
et: { translation: et },
eu: { translation: eu },
fa: { translation: fa },
fi: { translation: fi },
@@ -57,6 +61,8 @@ const resources = {
sr: { translation: sr },
sv: { translation: sv },
ta: { translation: ta },
th: { translation: th },
tl: { translation: tl },
tr: { translation: tr },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
@@ -87,6 +93,10 @@ export const languages = [
label: 'Español',
value: 'es',
},
{
label: 'Eesti',
value: 'et',
},
{
label: 'Basque',
value: 'eu',
@@ -163,6 +173,14 @@ export const languages = [
label: 'Tamil',
value: 'ta',
},
{
label: 'Thai',
value: 'th',
},
{
label: 'Tagalog',
value: 'tl',
},
{
label: 'Türkçe',
value: 'tr',
+45 -8
View File
@@ -331,7 +331,10 @@
"serverRequired": "يتطلب خادم",
"sessionExpiredError": "انتهت صلاحية جلستك",
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات"
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات",
"invalidJson": "JSON غير صالح",
"invalidServer": "خادم غير صالح",
"localFontAccessDenied": "تم رفض الوصول إلى الخطوط المحلية"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -372,7 +375,8 @@
"sortName": "أسم الفرز",
"title": "العنوان",
"toYear": "إلى سنة",
"trackNumber": "مقطع"
"trackNumber": "مقطع",
"isCompilation": "تجميعة"
},
"datetime": {
"minuteShort": "د",
@@ -413,14 +417,19 @@
"input_url": "الرابط",
"input_username": "أسم المستخدم",
"success": "تمت إضافة الخادم بنجاح",
"title": "إضافة خادم"
"title": "إضافة خادم",
"input_preferInstantMix": "تفضيل الميكس الفوري",
"input_preferInstantMixDescription": "استخدم الميكس الفوري فقط للحصول على أغاني مشابهة. مفيد إذا كان لديك إضافات تعدّل هذا السلوك",
"input_remoteUrlPlaceholder": "اختياري: عنوان URL عام للميزات الخارجية"
},
"largeFetchConfirmation": {
"title": "أضف العناصر إلى قائمة التشغيل"
"title": "أضف العناصر إلى قائمة التشغيل",
"description": "سيقوم هذا الإجراء بإضافة جميع العناصر في العرض المفلتر الحالي"
},
"addToPlaylist": {
"input_skipDuplicates": "تخطي العناصر المكررة",
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})"
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})",
"create": "إنشاء $t(entity.playlist, {\"count\": 1}) {{playlist}}"
},
"createPlaylist": {
"input_public": "عام"
@@ -428,7 +437,9 @@
"createRadioStation": {
"input_homepageUrl": "رابط الرئيسية",
"input_name": "الأسم",
"input_streamUrl": "رابط البث"
"input_streamUrl": "رابط البث",
"success": "تم إنشاء محطة راديو جديدة بنجاح",
"title": "إنشاء محطة راديو"
},
"editRadioStation": {
"success": "تم تحديث محطة الراديو بنجاح"
@@ -440,7 +451,8 @@
},
"editPlaylist": {
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
"title": "تعديل $t(entity.playlist, {\"count\": 1})"
"title": "تعديل $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "لسبب ما، لا يكشف Jellyfin عما إذا كانت قائمة التشغيل عامة أم لا. إذا كنت ترغب في إبقائها عامة، يرجى التأكد من تحديد الخيار التالي"
},
"lyricsExport": {
"export": "تصدير الكلمات",
@@ -451,7 +463,32 @@
},
"queryEditor": {
"input_optionMatchAll": "تطابق الجميع",
"input_optionMatchAny": "تطابق أي"
"input_optionMatchAny": "تطابق أي",
"title": "محرر الاستعلامات",
"addRuleGroup": "إضافة مجموعة قواعد",
"removeRuleGroup": "إزالة مجموعة قواعد",
"resetToDefault": "استعادة الإعدادات الافتراضية"
},
"shareItem": {
"allowDownloading": "السماح بالتحميل",
"description": "الوصف"
},
"shuffleAll": {
"title": "تشغيل عشوائي",
"input_kind_albums": "ألبومات",
"input_kind_songs": "أغاني",
"input_kind": "إختيارات عشوائية",
"input_minYear": "من سنة",
"input_maxYear": "إلى سنة"
},
"updateServer": {
"success": "تم تحديث الخادم بنجاح",
"title": "تحديث الخادم"
}
},
"page": {
"albumArtistDetail": {
"favoriteSongs": "الأغاني المفضلة"
}
}
}
+24 -8
View File
@@ -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",
+37 -21
View File
@@ -49,7 +49,8 @@
"sleepTimer_setCustom": "Nastavit časovač",
"sleepTimer_cancel": "Zrušit časovač",
"albumRadio": "Rádio alba",
"scrobbleForceSubmit": "Vynutit scrobble"
"scrobbleForceSubmit": "Vynutit scrobble",
"sleepTimer_endOfAlbum": "Konec aktuálního alba"
},
"setting": {
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
@@ -234,7 +235,7 @@
"customCssEnable": "Povolit vlastní CSS",
"customCssEnable_description": "Umožnit psaní vlastního CSS",
"customCssNotice": "Varování: i když provádíme určitou sanitizaci (zakázáním URL() a content:), může používání CSS stále představovat riziko změnami rozhraní",
"customCss_description": "Vlastní CSS obsah. Upozornění: vlastnosti content a vzdálené URL jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace",
"customCss_description": "Vlastní CSS obsah. Upozornění: vlastnosti content a vzdálené URL jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace. Počítačový Feishin čte a zapisuje soubor custom.css do konfiguračního adresáře aplikace a znovu jej načte po jeho změně",
"customCss": "Vlastní css",
"webAudio": "Použít webový zvuk",
"webAudio_description": "Použít webový zvuk. Tím povolíte pokročilé funkce jako ReplayGain. Zakažte, pokud se objeví problémy",
@@ -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í",
@@ -345,7 +346,7 @@
"playerbarSlider_description": "Vlnová křivka není doporučena, pokud se nacházíte na pomalém nebo měřeném internetovém připojení",
"autoDJ": "Automatický DJ",
"autoDJ_itemCount": "Počet položek",
"autoDJ_itemCount_description": "Počet položek, které se pokusíme přidat do fronty po povolení automatického DJ",
"autoDJ_itemCount_description": "Počet položek, které se pokusíme přidat do fronty",
"autoDJ_timing": "Časování",
"autoDJ_timing_description": "Počet skladeb zbývajících ve frontě před spuštěním automatického DJ",
"logLevel": "Úroveň protokolu",
@@ -447,7 +448,16 @@
"sidebarPlaylistMode_description": "Jak je každý seznam skladeb zobrazen v seznamu v postranní liště",
"sidebarPlaylistMode": "Režim seznamů skladeb v postranní liště",
"sidebarPlaylistMode_optionCompact": "Kompaktní",
"sidebarPlaylistMode_optionExpanded": "Rozšířený"
"sidebarPlaylistMode_optionExpanded": "Rozšířený",
"autoDJ_mode": "Režim",
"autoDJ_mode_albums": "Alba",
"autoDJ_mode_description": "Vyberte, zda do fronty přidávat skladby nebo celá alba",
"autoDJ_mode_songs": "Skladby",
"autoDJ_enabled": "Povolit automatického DJ",
"autoDJ_albumStrategy": "Režim výběru alb",
"autoDJ_songStrategy": "Režim výběru skladeb",
"autoDJ_strategy_option_library_random": "Náhodně",
"autoDJ_strategy_option_similar": "Podobné"
},
"action": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
@@ -539,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",
@@ -560,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í",
@@ -623,7 +633,8 @@
"newVersionAvailable": "Je dostupná nová verze",
"numberOfResults": "{{numberOfResults}} výsledků",
"grouping": "Seskupování",
"back": "Zpět"
"back": "Zpět",
"openFolder": "Otevřít složku"
},
"table": {
"config": {
@@ -1122,7 +1133,12 @@
"input_played": "Přehrát filtr",
"input_played_optionAll": "Všechny skladby",
"input_played_optionUnplayed": "Pouze nepřehrané skladby",
"input_played_optionPlayed": "Pouze přehrané skladby"
"input_played_optionPlayed": "Pouze přehrané skladby",
"input_kind_albums": "Alba",
"input_kind_songs": "Skladby",
"input_kind": "Náhodný výběr",
"input_limit_albums": "Kolik alb?",
"input_limit_songs": "Kolik skladeb?"
},
"saveQueue": {
"success": "Fronta přehrávání uložena na server"
@@ -1145,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",
@@ -1163,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",
@@ -1172,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",
@@ -1193,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",
+42 -33
View File
@@ -13,7 +13,7 @@
"removeFromPlaylist": "Aus $t(entity.playlist, {\"count\": 1}) entfernen",
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
"refresh": "$t(common.refresh)",
"removeFromQueue": "Aus wiedergabeliste entfernen",
"removeFromQueue": "Aus Wiedergabeliste entfernen",
"setRating": "Bewertung setzen",
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
@@ -169,7 +169,8 @@
"filter_multiple": "Mehrfach",
"retry": "Erneut versuchen",
"newVersionAvailable": "Eine neue version ist verfügbar",
"numberOfResults": "{{numberOfResults}} ergebnisse"
"numberOfResults": "{{numberOfResults}} ergebnisse",
"openFolder": "Verzeichnis öffnen"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -191,13 +192,13 @@
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
"invalidServer": "Ungültiger Server",
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut",
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Dieses Problem tritt meist auf, wenn sich ein Lied im Überordner befindet. Jellyfin gruppiert Tracks nur, wenn diese sich innerhalb eines Ordners befinden",
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Dieses Problem tritt meist auf, wenn sich ein Lied im Überordner befindet. Jellyfin gruppiert Tracks nur, wenn diese sich innerhalb eines Verzeichnisses befinden",
"networkError": "Ein Netzwerkfehler ist aufgetreten",
"openError": "Datei kann nicht geöffnet werden",
"badValue": "Ungültige option \"{{value}}\". Dieser Wert existiert nicht mehr",
"notificationDenied": "Berechtigungen über Benachrichtigungen wurden verweigert. Diese Einstellung hat keinen Effekt",
"saveQueueFailed": "Wiedergabeliste konnte nicht gespeichert werden",
"multipleServerSaveQueueError": "Die Wiedergabeliste enthält einen oder mehrere Titel, die nicht vom aktuellen Server stammen. dies wird nicht unterstützt",
"multipleServerSaveQueueError": "Die Wiedergabeliste enthält einen oder mehrere Titel, die nicht vom aktuellen Server stammen. Dies wird nicht unterstützt",
"noNetwork": "Server nicht verfügbar",
"noNetworkDescription": "Verbindung zum Server konnte nicht hergestellt werden",
"invalidJson": "JSON ungültig",
@@ -309,7 +310,7 @@
"editPlaylist": {
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen, ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
},
"lyricSearch": {
"title": "Songtext suche",
@@ -332,7 +333,7 @@
"title": "Privater Modus"
},
"largeFetchConfirmation": {
"title": "Elemente der wiedergabeliste hinzufügen",
"title": "Elemente der Wiedergabeliste hinzufügen",
"description": "Diese Aktion fügt alle Elemente in der aktuell gefilterten Ansicht hinzu"
},
"shuffleAll": {
@@ -347,7 +348,7 @@
"input_played": "Wiedergabefilter"
},
"saveQueue": {
"success": "Wiedergabeliste auf server gespeichert"
"success": "Wiedergabeliste auf Server gespeichert"
},
"createRadioStation": {
"success": "Radiosender erfolgreich erstellt",
@@ -368,14 +369,14 @@
"entity": {
"genre_one": "Genre",
"genre_other": "Genres",
"playlistWithCount_one": "{{count}} wiedergabeliste",
"playlistWithCount_other": "{{count}} wiedergabelisten",
"playlistWithCount_one": "{{count}} Wiedergabeliste",
"playlistWithCount_other": "{{count}} Wiedergabelisten",
"playlist_one": "Wiedergabeliste",
"playlist_other": "Wiedergabelisten",
"artist_one": "Interpret",
"artist_other": "Interpreten",
"folderWithCount_one": "{{count}} verzeichnis",
"folderWithCount_other": "{{count}} verzeichnisse",
"folderWithCount_one": "{{count}} Verzeichnis",
"folderWithCount_other": "{{count}} Verzeichnisse",
"albumArtist_one": "Albuminterpret",
"albumArtist_other": "Albuminterpreten",
"track_one": "Track",
@@ -552,9 +553,9 @@
"privateModeOff": "Privaten Modus deaktivieren",
"privateModeOn": "Privaten Modus aktivieren",
"commandPalette": "Kommandopalette öffnen",
"selectMusicFolder": "Musikordner wählen",
"noMusicFolder": "Kein musikordner gewählt",
"multipleMusicFolders": "{{count}} musikordner ausgewählt"
"selectMusicFolder": "Musikverzeichnis wählen",
"noMusicFolder": "Kein Musikverzeichnis gewählt",
"multipleMusicFolders": "{{count}} Musikverzeichnis ausgewählt"
},
"home": {
"mostPlayed": "Meistgespielt",
@@ -681,7 +682,7 @@
"topSongs": "Toplieder",
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
"groupingTypeAll": "Alle veröffentlichungsformate",
"groupingTypePrimary": "Primäre veröffentlichungsformate",
"groupingTypePrimary": "Primäre Veröffentlichungsformate",
"favoriteSongs": "Lieblingslieder",
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
"topSongsCommunity": "Community",
@@ -761,8 +762,8 @@
"addLastShuffled": "Als Letztes (zufällige Wiedergabe)",
"addNextShuffled": "Als Nächstes (zufällige Wiedergabe)",
"holdToShuffle": "Halten für zufallswiedergabe",
"restoreQueueFromServer": "Wiedergabeliste von server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf server speichern",
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf Server speichern",
"lyrics": "Songtexte",
"artistRadio": "Künstler radio",
"sleepTimer_endOfSong": "Ende des aktuellen liedes",
@@ -900,13 +901,13 @@
"sidebarPlaylistSorting": "Wiedergabelisten-sortierung in der seitenleiste",
"minimizeToTray": "Zur taskleiste minimieren",
"skipPlaylistPage": "Wiedergabeliste-seite überspringen",
"themeDark": "Erscheinungsbild (dunkel)",
"themeDark": "Design (dunkel)",
"sidebarCollapsedNavigation": "Navigation in der seitenleiste (komprimiert)",
"gaplessAudio_optionWeak": "Schwach (empfohlen)",
"minimumScrobbleSeconds": "Minimum scrobble-dauer (sekunden)",
"hotkey_playbackStop": "Stoppen",
"savePlayQueue_description": "Speichert die Wiedergabeliste beim Schließen der Anwendung, und stellt diese wieder her, wenn die Anwendung geöffnet wird",
"useSystemTheme": "Nach erscheinungsbild des systems richten",
"useSystemTheme": "Nach Erscheinungsbild des Systems richten",
"enableRemote_description": "Aktiviert den Server für die Fernsteuerung, damit andere Geräte die Anwendung steuern können",
"fontType_optionSystem": "System schriftart",
"discordUpdateInterval": "{{discord}} rich presence aktualisierungsintervall",
@@ -922,7 +923,7 @@
"fontType": "Schriftartenquelle",
"followLyric": "Aktuellen songtext synchronisieren",
"font_description": "Wähle die Schriftart für die Anwendung",
"themeLight": "Erscheinungsbild (hell)",
"themeLight": "Design (hell)",
"sidePlayQueueStyle_optionDetached": "Lösgelöst",
"windowBarStyle_description": "Legt das Erscheinungsbild des Fensterrahmens fest",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu favoriten hinzufügen",
@@ -950,7 +951,7 @@
"albumBackgroundBlur_description": "Passt die Stärke der Unschärfe an, welche auf das Hintergrundbild des Albums angewandt wird",
"clearCacheSuccess": "Cache erfolgreich geleert",
"contextMenu": "Kontextmenü-einstellungen (rechtsklick)",
"customCssEnable_description": "Erlaubt das hinzufügen von benutzerdefiniertem CSS",
"customCssEnable_description": "Erlaubt das Hinzufügen von benutzerdefiniertem CSS",
"artistBackground": "Künstler hintergrundbild",
"artistBackground_description": "Fügt ein Hintergrundbild für die Künstlerseite hinzu",
"artistConfiguration": "Künstler albumseite konfiguration",
@@ -980,8 +981,8 @@
"logLevel_optionWarn": "Warnung",
"autoDJ": "Auto DJ",
"autoDJ_itemCount": "Anzahl",
"autoDJ_itemCount_description": "Die anzahl der lieder, die bei aktiviertem auto DJ zur wiedergabeliste hinzugefügt werden sollen",
"autoDJ_timing_description": "Die anzahl der lieder, die sich noch in der wiedergabeliste befinden, bevor auto DJ ausgelöst wird",
"autoDJ_itemCount_description": "Die Anzahl der Lieder, die zur Wiedergabeliste hinzugefügt werden soll",
"autoDJ_timing_description": "Die Anzahl der Lieder, die sich noch in der Wiedergabeliste befinden, bevor Auto-DJ ausgelöst wird",
"autoDJ_timing": "Timing",
"discordDisplayType": "{{discord}} presence darstellungsart",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} mit {{lastfm}} als ersatz",
@@ -1022,7 +1023,7 @@
"artistConfiguration_description": "Legt fest, welche Elemente auf der Albumkünstlerseite angezeigt werden und in welcher Reihenfolge",
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
"crossfadeStyle": "Art der überblende",
"customCss_description": "Benutzerdefinierter CSS-inhalt. Hinweis: inhalte und remote-urls sind nicht zulässige eigenschaften. Unten siehst du eine vorschau deines inhalts. Aufgrund von bereinigung werden womöglich zusätzliche, nicht von dir definierte felder angezeigt",
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Content und Remote URLs sind nicht zulässige Eigenschaften. Eine Vorschau deines Inhalts wird unten angezeigt. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt. Desktop: Feishin liest und schreibt in eine custom.css Datei im App-Konfigurationsverzeichnis, und lädt diese neu, wenn sich die Datei ändert.",
"customCssNotice": "Warnung: obwohl eine gewisse bereinigung erfolgt (nicht zulässig sind z. B. \"URL()\" und \"content:\"), kann ein benutzerdefiniertes CSS risiken mit sich bringen, da die benutzeroberfläche dadurch verändert wird",
"releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Stabil",
@@ -1064,7 +1065,7 @@
"automaticUpdates": "Automatische updates",
"automaticUpdates_description": "Updates automatisch suchen und installieren",
"releaseChannel_optionAlpha": "Alpha (nightly)",
"useThemeAccentColor": "Akzentfarbe des themas nutzen",
"useThemeAccentColor": "Standard Akzentfarbe übernehmen",
"analyticsEnable_description": "Anonymisierte Nutzungsdaten werden an den Entwickler gesendet, um die Anwendung zu verbessern",
"artistReleaseTypeConfiguration_description": "Konfigurieren, welche Release-Typen und in welcher Reihenfolge diese auf der Album-Künstlerseite angezeigt werden",
"homeConfiguration_description": "Konfigurieren, welche Elemente und in welcher Reihenfolge diese auf der Startseite angezeigt werden",
@@ -1111,14 +1112,14 @@
"queryBuilder": "Abfrage-editor",
"queryBuilderCustomFields_inputLabel": "Label",
"queryBuilderCustomFields_description": "Füge benutzerdefinierte Felder für den Abfrage-Editor hinzu",
"autosave": "Automatisch aktuelle wiedergabeliste speichern",
"autosave_description": "Aktiviere die automatische speicherung der aktuellen wiedergabe auf dem server. Diese funktion ist nur bei Navidrome/Subsonic servern verfügbar und es darf sich nicht um eine gemischte wiedergabeliste handeln.",
"autosaveCount": "Häufigkeit der automatischen speicherung bei wiedergabelisten",
"autosave": "Automatisch aktuelle Wiedergabeliste speichern",
"autosave_description": "Aktiviere die automatische Speicherung der aktuellen Wiedergabe auf dem Server. Diese Funktion ist nur bei Navidrome/Subsonic Servern verfügbar und es darf sich nicht um eine gemischte Wiedergabeliste handeln.",
"autosaveCount": "Häufigkeit der automatischen Speicherung bei Wiedergabelisten",
"autosaveCount_description": "Wieviele Lieder gespielt werden, bevor die Wiedergabeliste gespeichert wird. 1 (Minimum) bedeutet die Speicherung nach jedem gespielten Lied",
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
"useThemePrimaryShade": "Primärschatten des themas nutzen",
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
"primaryShade": "Primärschatten",
"useThemeAccentColor_description": "Verwendet die primäre Farbe des gewählten Designs",
"useThemePrimaryShade": "Standard Farbton übernehmen",
"useThemePrimaryShade_description": "Verwendet den primären Farbton des ausgewählten Designs für die Primärfarbvarianten",
"primaryShade": "Primärer Farbton",
"listenbrainz": "ListenBrainz Links anzeigen",
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
"mpvExtraParameters": "Zusätzliche mpv parameter",
@@ -1133,7 +1134,15 @@
"nativeSpotify_description": "In der Spotify app statt im browser öffnen",
"imageResolution_optionFullScreenPlayer": "Wiedergabe im vollbildmodus",
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertikal"
"sidePlayQueueLayout_optionVertical": "Vertikal",
"sidebarPlaylistFolders": "Verzeichnisse aktivieren",
"sidebarPlaylistFolderSeparator": "Verzeichnistrennzeichen",
"sidebarPlaylistFolderView_description": "Wie Verzeichnisse in der Seitenleiste angezeigt werden",
"sidebarPlaylistFolderView": "Verzeichnisansicht",
"sidebarPlaylistFolderView_optionSingle": "Einzelne Ordner",
"sidebarPlaylistFolderView_optionTree": "Baumstruktur",
"sidebarPlaylistFolderView_optionNavigation": "Navigationsansicht",
"sidebarPlaylistFolderSeparator_description": "Zeichen (oder Zeichenfolge), das die Verzeichnisebenen im Wiedergabelistentitel trennt"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
+2 -1
View File
@@ -699,6 +699,7 @@
"viewQueue": "View queue",
"sleepTimer": "Sleep timer",
"sleepTimer_endOfSong": "End of current song",
"sleepTimer_endOfAlbum": "End of current album",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "Custom",
@@ -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",
+23 -7
View File
@@ -49,7 +49,8 @@
"sleepTimer_endOfSong": "Fin de la canción actual",
"sleepTimer": "Temporizador de apagado",
"albumRadio": "Radio del álbum",
"scrobbleForceSubmit": "Forzar scrobble"
"scrobbleForceSubmit": "Forzar scrobble",
"sleepTimer_endOfAlbum": "Fin del álbum actual"
},
"setting": {
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -235,7 +236,7 @@
"customCssEnable_description": "Permite escribir CSS personalizado",
"customCss": "CSS personalizado",
"customCssNotice": "Aviso: mientras hay alguna sanitización (rechazar URL() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz",
"customCss_description": "Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización",
"customCss_description": "Content CSS personalizado. Nota: content y remote urls son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización. Escritorio: Feishin lee y escribe custom.css en el directorio de configuración de la aplicación y lo recarga cuando cambia el archivo",
"webAudio": "Usar audio web",
"webAudio_description": "Utilizar audio web. Esto habilita funciones avanzadas como ReplayGain. Desactiva esta opción si tienes problemas",
"transcode_description": "Permite la transcodificación a distintos formatos",
@@ -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",
@@ -345,7 +346,7 @@
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
"autoDJ": "DJ Automático",
"autoDJ_itemCount": "Recuento de elementos",
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola",
"autoDJ_timing_description": "El número de canciones restantes en la cola antes de que DJ automático se dispare",
"autoDJ_timing": "Tiempo",
"logLevel": "Nivel de registro",
@@ -447,7 +448,16 @@
"sidebarPlaylistMode_optionCompact": "Compacto",
"sidebarPlaylistMode_optionExpanded": "Expandido",
"sidebarPlaylistMode_description": "Cómo se muestra cada lista de reproducción en la lista de la barra lateral",
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol"
"sidebarPlaylistFolderTreeIndent_description": "Píxeles que está sangrado cada nivel del árbol",
"autoDJ_mode": "Modo",
"autoDJ_mode_albums": "Álbumes",
"autoDJ_mode_songs": "Canciones",
"autoDJ_enabled": "Activar DJ automático",
"autoDJ_albumStrategy": "Modo de selección de álbum",
"autoDJ_songStrategy": "Modo de selección de canción",
"autoDJ_strategy_option_library_random": "Aleatorio",
"autoDJ_strategy_option_similar": "Similar",
"autoDJ_mode_description": "Elegir para añadir canciones o álbumes enteros a la cola"
},
"action": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
@@ -623,7 +633,8 @@
"newVersionAvailable": "Una nueva versión está disponible",
"numberOfResults": "{{numberOfResults}} resultados",
"grouping": "Agrupar",
"back": "Atrás"
"back": "Atrás",
"openFolder": "Abrir carpeta"
},
"error": {
"remotePortWarning": "Reiniciar el servidor para aplicar el nuevo puerto",
@@ -1013,7 +1024,12 @@
"input_played": "Reproducir filtro",
"input_played_optionAll": "Todas las pistas",
"input_played_optionUnplayed": "Solo las pistas sin reproducir",
"input_played_optionPlayed": "Solo las pistas reproducidas"
"input_played_optionPlayed": "Solo las pistas reproducidas",
"input_kind_albums": "Álbumes",
"input_kind_songs": "Canciones",
"input_limit_albums": "¿Cuántos álbumes?",
"input_limit_songs": "¿Cuántas canciones?",
"input_kind": "Selecciones aleatorias"
},
"saveQueue": {
"success": "Cola de reproducción guardada en el servidor"
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -49,7 +49,8 @@
"sleepTimer_setCustom": "Définir le minuteur",
"sleepTimer_cancel": "Annuler le minuteur",
"albumRadio": "Radio d'album",
"scrobbleForceSubmit": "Forcer le scrobble"
"scrobbleForceSubmit": "Forcer le scrobble",
"sleepTimer_endOfAlbum": "Fin de l'album actuel"
},
"action": {
"editPlaylist": "Éditer $t(entity.playlist, {\"count\": 1})",
@@ -226,7 +227,8 @@
"rename": "Renommer",
"newVersionAvailable": "Une nouvelle version est disponible",
"numberOfResults": "{{numberOfResults}} résultats",
"back": "Retour"
"back": "Retour",
"openFolder": "Ouvrir le dossier"
},
"error": {
"remotePortWarning": "Redémarrer le serveur pour appliquer le nouveau port",
@@ -728,7 +730,7 @@
"translationTargetLanguage": "Langue cible de traduction",
"trayEnabled": "Afficher la barre d’état système",
"translationApiProvider_description": "Fournisseur d'API pour la traduction",
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement",
"customCss_description": "Contenu CSS personnalisé. Remarque : les propriétés 'content' et les URL distantes ne sont pas autorisées. Un aperçu de votre contenu est affiché ci-dessous. Des champs supplémentaires que vous n'avez pas définis sont présents en raison d'assainissement. Application de Bureau uniquement: feishin lit et écrit le fichier custom.css dans le répertoire de configuration de l'application et le recharge lorsque celui-ci est modifié",
"translationApiKey": "Clé API de traduction",
"translationTargetLanguage_description": "Langue cible pour la traduction",
"trayEnabled_description": "Afficher/masquer licône/le menu dans la barre d’état système. si désactivé, désactive également la réduction/fermeture vers la barre d’état système",
@@ -813,7 +815,7 @@
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
"autoDJ": "DJ auto",
"autoDJ_itemCount": "Nombre d'entrée",
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente lorsque le DJ auto est activé",
"autoDJ_itemCount_description": "Le nombre d'entrées tentées d'être ajoutées à la file d'attente",
"autoDJ_timing": "Timing",
"autoDJ_timing_description": "Le nombre de titres restant dans la file d'attente avant le déclenchement du DJ auto",
"followCurrentSong_description": "Défiler automatiquement la file d'attente jusqu'au titre en cours",
@@ -915,7 +917,16 @@
"sidebarPlaylistFolderTreeIndent": "Indentation de l'arbre",
"sidebarPlaylistMode_description": "Comment chaque liste de lecture est affichée dans la barre latérale",
"sidebarPlaylistMode": "Mode de liste de lecture de la barre latérale",
"sidebarPlaylistMode_optionCompact": "Compacte"
"sidebarPlaylistMode_optionCompact": "Compacte",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Albums",
"autoDJ_mode_description": "Choisissez d'ajouter des titres ou des albums entiers à la file d'attente",
"autoDJ_mode_songs": "Titres",
"autoDJ_enabled": "Activer le DJ auto",
"autoDJ_albumStrategy": "Mode de sélection d'album",
"autoDJ_songStrategy": "Mode de sélection de titre",
"autoDJ_strategy_option_library_random": "Aléatoire",
"autoDJ_strategy_option_similar": "Similaire"
},
"form": {
"deletePlaylist": {
@@ -1009,7 +1020,12 @@
"input_played": "Filtre de lecture",
"input_played_optionAll": "Toutes les pistes",
"input_played_optionUnplayed": "Seulement les pistes non jouées",
"input_played_optionPlayed": "Seulement les pistes jouées"
"input_played_optionPlayed": "Seulement les pistes jouées",
"input_kind_songs": "Titres",
"input_kind_albums": "Albums",
"input_kind": "Sélections aléatoires",
"input_limit_albums": "Combien d'albums?",
"input_limit_songs": "Combien de titres?"
},
"createRadioStation": {
"success": "Station radio créée avec succès",
+39 -23
View File
@@ -49,7 +49,8 @@
"albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する",
"scrobbleForceSubmit": "強制 Scrobble"
"scrobbleForceSubmit": "強制 Scrobble",
"sleepTimer_endOfAlbum": "現在のアルバムの終了"
},
"setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -430,12 +431,21 @@
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
"preventSuspendOnPlayback": "再生の中断を防止する",
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
"autoDJ_mode": "モード",
"autoDJ_mode_albums": "アルバム",
"autoDJ_mode_description": "キューに曲を追加するか、アルバム全体を追加するかを選択してください。",
"autoDJ_mode_songs": "曲",
"autoDJ_enabled": "Auto DJを有効にする",
"autoDJ_albumStrategy": "アルバム選択モード",
"autoDJ_songStrategy": "選曲モード",
"autoDJ_strategy_option_library_random": "ランダム",
"autoDJ_strategy_option_similar": "類似"
},
"action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
"goToPage": "ページへ移動",
"moveToTop": "先頭に移動",
"moveToTop": "一番上へ移動",
"clearQueue": "キューをクリア",
"addToFavorites": "$t(entity.favorite, {\"count\": 2}) に追加",
"addToPlaylist": "$t(entity.playlist, {\"count\": 1}) に追加",
@@ -446,9 +456,9 @@
"deletePlaylist": "$t(entity.playlist, {\"count\": 1}) を削除",
"removeFromQueue": "キューから削除",
"deselectAll": "すべて選択解除",
"moveToBottom": "末尾に移動",
"setRating": "評価を設定する",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディタ切り替え",
"moveToBottom": "一番下へ移動",
"setRating": "評価を設定",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) エディターを切り替え",
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
"openIn": {
"lastfm": "Last.fm で開く",
@@ -457,9 +467,9 @@
"listenbrainz": "ListenBrainz で開く",
"qobuz": "Qobuz で開く"
},
"moveToNext": "次",
"moveToNext": "次へ進む",
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
"moveItems": "を移動",
"moveItems": "項目を移動",
"shuffle": "シャッフル",
"shuffleAll": "すべてシャッフル",
"shuffleSelected": "選択した曲をシャッフル",
@@ -471,28 +481,28 @@
"moveDown": "下に移動",
"holdToMoveToTop": "押し続けると一番上に移動します",
"holdToMoveToBottom": "押し続けると一番下に移動します",
"openApplicationDirectory": "アプリケーションディレクトリを開く",
"openApplicationDirectory": "アプリディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除",
"addOrRemoveFromSelection": "選択に追加または選択から除外",
"goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
},
"common": {
"backward": "戻る",
"backward": "逆行",
"increase": "増加",
"rating": "評価",
"bpm": "BPM",
"refresh": "再読み込み",
"unknown": "不明",
"areYouSure": "実行しすか?",
"areYouSure": "実行してもよろしいですか",
"edit": "編集",
"favorite": "お気に入り",
"left": "左側",
"save": "保存",
"right": "右側",
"currentSong": "現在の $t(entity.track, {\"count\": 1})",
"collapse": "折りたた",
"collapse": "折りたた",
"trackNumber": "トラック",
"descending": "降順",
"add": "追加",
@@ -534,7 +544,7 @@
"confirm": "確認",
"resetToDefault": "デフォルトにリセット",
"home": "ホーム",
"comingSoon": "近日利用可能になる予定です…",
"comingSoon": "近日公開…",
"reset": "リセット",
"channel_other": "チャンネル",
"disable": "無効",
@@ -543,7 +553,7 @@
"menu": "メニュー",
"restartRequired": "再起動が必要です",
"previousSong": "前の $t(entity.track, {\"count\": 1})",
"noResultsFromQuery": "条件にマッチするものがありません",
"noResultsFromQuery": "クエリに一致する結果がありません",
"quit": "終了",
"expand": "展開",
"search": "検索",
@@ -553,11 +563,11 @@
"random": "ランダム",
"size": "サイズ",
"biography": "バイオグラフィー",
"note": "ノート",
"note": "注記",
"explicitStatus": "明示的なステータス",
"additionalParticipants": "追加参加者",
"newVersion": "新しいバージョン ({{version}}) がインストールされました",
"viewReleaseNotes": "リリースノートを表示する",
"viewReleaseNotes": "リリースノートを表示",
"bitDepth": "ビット深度",
"close": "閉じる",
"codec": "コーデック",
@@ -565,7 +575,7 @@
"sampleRate": "サンプルレート",
"preview": "プレビュー",
"private": "プライベート",
"public": "パブリック",
"public": "公開",
"share": "共有",
"tags": "タグ",
"trackGain": "トラックゲイン",
@@ -598,7 +608,8 @@
"newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化",
"back": "戻る"
"back": "戻る",
"openFolder": "フォルダーを開く"
},
"table": {
"config": {
@@ -710,7 +721,7 @@
}
},
"error": {
"remotePortWarning": "新たなポート設定を適用するためサーバーを再起動してください",
"remotePortWarning": "新しいポート設定を反映させるには、サーバーを再起動してください",
"systemFontError": "システムフォントを取得する際にエラーが発生しました",
"playbackError": "メディアの再生開始時にエラーが発生しました",
"remotePortError": "リモートサーバーのポート設定時にエラーが発生しました",
@@ -725,7 +736,7 @@
"serverNotSelectedError": "サーバーが選択されていません",
"remoteDisableError": "リモートサーバーを$t(common.disable)にする際にエラーが発生しました",
"mpvRequired": "MPV が必要です",
"audioDeviceFetchError": "オーディオデバイス取得にエラーが発生しました",
"audioDeviceFetchError": "オーディオデバイス取得しようとした際にエラーが発生しました",
"invalidServer": "無効なサーバー",
"loginRateError": "ログイン試行回数が多すぎます。数秒後に再試行してください",
"endpointNotImplementedError": "{{serverType}} にはエンドポイント {{endpoint}} が実装されていません",
@@ -733,7 +744,7 @@
"networkError": "ネットワークエラーが発生しました",
"notificationDenied": "通知の許可が拒否されました。この設定は効果がありません",
"openError": "ファイルを開けませんでした",
"badValue": "無効なオプション「{{value}}」。この値は存在しません",
"badValue": "無効なオプション「{{value}}」です。この値は存在しません",
"multipleServerSaveQueueError": "再生キューに現在のサーバーに存在しない曲が 1 曲以上あります。これはサポートされていません",
"noNetwork": "サーバーが利用できません",
"noNetworkDescription": "このサーバーに接続できませんでした",
@@ -1109,7 +1120,12 @@
"input_played_optionAll": "すべてのトラック",
"input_played_optionUnplayed": "未再生のトラックのみ",
"input_played_optionPlayed": "再生されたトラックのみ",
"input_played": "再生フィルター"
"input_played": "再生フィルター",
"input_kind_albums": "アルバム",
"input_kind_songs": "曲",
"input_kind": "ランダムピック",
"input_limit_albums": "アルバムは何枚ですか?",
"input_limit_songs": "何曲ですか?"
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
+244 -24
View File
@@ -17,7 +17,10 @@
"removeFromPlaylist": "$t(entity.playlist, {\"count\": 1})에서 제거",
"openIn": {
"musicbrainz": "MusicBrainz에서 보기",
"lastfm": "Last.fm에서 보기"
"lastfm": "Last.fm에서 보기",
"listenbrainz": "ListenBrainz에서 열기",
"qobuz": "Qobuz에서 열기",
"spotify": "Spotify에서 열기"
},
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) 보기",
"setRating": "평점 지정",
@@ -37,7 +40,10 @@
"shuffleAll": "모두 섞기",
"shuffleSelected": "선택항목 섞기",
"viewMore": "더 보기",
"openApplicationDirectory": "앱 디렉토리 열기"
"openApplicationDirectory": "앱 디렉토리 열기",
"goToCurrent": "현재 항목으로 이동",
"collapseAllFolders": "모든 폴더 접기",
"expandAllFolders": "모든 폴더 확장"
},
"common": {
"translation": "번역",
@@ -149,7 +155,18 @@
"sort": "정렬",
"gridRows": "행 그리드",
"tableColumns": "테이블 열",
"itemsMore": "{{count}}개 더"
"itemsMore": "{{count}}개 더",
"back": "뒤로",
"example": "예",
"openFolder": "폴더 열기",
"filter_single": "미혼",
"filter_multiple": "다중",
"grouping": "그룹화",
"mood": "기분",
"numberOfResults": "결과 {{numberOfResults}}개",
"retry": "다시 해 보다",
"rename": "이름 변경",
"newVersionAvailable": "새로운 버전이 나왔습니다"
},
"entity": {
"albumWithCount_other": "{{count}} 앨범",
@@ -197,7 +214,15 @@
"localFontAccessDenied": "로컬 글꼴에 접근 거부되었습니다",
"apiRouteError": "요청 보내기 실패",
"badValue": "옵션이 없습니다 {{value}}. 이 값은 더이상 존재하지 않습니다",
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다"
"notificationDenied": "알림에 대한 권한이 거부되었습니다. 이 설정은 변경되지 않습니다",
"invalidJson": "유효하지 않은 JSON",
"multipleServerSaveQueueError": "재생 대기열에 현재 서버에 속하지 않은 곡이 하나 이상 포함되어 있습니다. 이는 지원되지 않습니다",
"noNetwork": "서버를 이용할 수 없음",
"noNetworkDescription": "이 서버에 연결할 수 없습니다",
"playbackPausedDueToError": "오류로 인해 재생이 일시 중지되었습니다",
"saveQueueFailed": "큐 저장 실패",
"serverLockSingleServer": "서버가 잠겨 있을 때는 서버를 하나만 허용합니다",
"settingsSyncError": "렌더러와 메인 프로세스의 설정 간에 불일치가 발견되었습니다. 변경 사항을 적용하려면 애플리케이션을 다시 시작하십시오"
},
"filter": {
"title": "곡명",
@@ -222,7 +247,7 @@
"disc": "디스크",
"bitrate": "비트 전송률",
"biography": "바이오그래피",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"duration": "길이",
"bpm": "BPM",
"albumCount": "$t(entity.album, {\"count\": 2}) 앨범수",
@@ -242,7 +267,10 @@
"songCount": "곡 갯수",
"toYear": "년도까지",
"trackNumber": "트랙",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "그리고",
"matchOr": "또는",
"sortName": "이름 정렬"
},
"form": {
"addServer": {
@@ -258,7 +286,10 @@
"input_legacyAuthentication": "레거시 인증 사용",
"input_username": "유저 이름",
"input_preferInstantMix": "즉석 믹스 선호",
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다"
"input_preferInstantMixDescription": "비슷한 곳을 찾기 위해 즉석 믹스를 사용합니다. 이 명령을 수정하기 위한 플러그인을 설치한 경우 유용합니다",
"input_preferRemoteUrl": "공개 URL 선호",
"input_remoteUrl": "공개 URL",
"input_remoteUrlPlaceholder": "선택 사항: 외부 기능을 위한 공개 URL"
},
"addToPlaylist": {
"input_skipDuplicates": "중복 건너뛰기",
@@ -266,7 +297,8 @@
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"success": "$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })에 $t(entity.trackWithCount, {\"count\": {{message}} })가 추가되었습니다",
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} 생성",
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기"
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) 검색 또는 입력하여 새로 만들기",
"noneAdded": "$t(entity.playlist, {\"count\": 1}) '{{playlist}}'에 트랙이 추가되지 않았습니다"
},
"lyricSearch": {
"title": "가사 검색",
@@ -276,7 +308,11 @@
"queryEditor": {
"input_optionMatchAll": "모두 일치",
"input_optionMatchAny": "무엇이든 일치",
"title": "쿼리 편집기"
"title": "쿼리 편집기",
"addRuleGroup": "규칙 그룹 추가",
"removeRuleGroup": "규칙 그룹 제거",
"resetToDefault": "기본값으로 초기화",
"clearFilters": "필터 초기화"
},
"editPlaylist": {
"title": "$t(entity.playlist, {\"count\": 1}) 편집",
@@ -289,7 +325,9 @@
"success": "클립보드에 공유 링크를 복사했습니다 (또는 열어보려면 클릭하세요)",
"expireInvalid": "만료 날짜는 미래 날짜여야만 합니다",
"createFailed": "공유 링크를 생성하는데 실패하였습니다 (혹시 공유하기 설정되어 있나요?)",
"setExpiration": "만료 기간 설정하기"
"setExpiration": "만료 기간 설정하기",
"copyToClipboard": "클립보드로 복사: Ctrl+C, Enter",
"successMustClick": "공유가 성공적으로 생성되었습니다. 여기를 클릭하여 여세요"
},
"updateServer": {
"title": "서버 업데이트",
@@ -312,6 +350,44 @@
"enabled": "프라이빗 모드가 활성화되었습니다. 재생상태가 외부 서비스에 지금부터 노출되지 않습니다",
"disabled": "프라이빗 모드가 비활성화되었습니다. 재생상태가 외부서비스에서 지금부터 표시됩니다",
"title": "프라이빗 모드"
},
"largeFetchConfirmation": {
"title": "대기열에 항목을 추가하세요",
"description": "이 작업은 현재 필터링된 보기의 모든 항목을 추가합니다"
},
"createRadioStation": {
"success": "라디오 방송국이 성공적으로 생성되었습니다",
"title": "라디오 방송국 만들기",
"input_homepageUrl": "홈페이지 URL",
"input_name": "명의",
"input_streamUrl": "스트림 URL"
},
"editRadioStation": {
"success": "라디오 방송국이 성공적으로 업데이트되었습니다"
},
"lyricsExport": {
"export": "가사 내보내기",
"input_synced": "동기화된 가사 내보내기",
"input_offset": "$t(setting.lyricOffset)"
},
"saveQueue": {
"success": "재생 대기열을 서버에 저장했습니다"
},
"shuffleAll": {
"title": "무작위 재생",
"input_kind_albums": "앨범",
"input_kind_songs": "노래들",
"input_kind": "무작위 선택",
"input_limit_albums": "앨범이 몇 장인가요?",
"input_limit_songs": "몇 곡인가요?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "몇 곡인가요?",
"input_minYear": "연도부터",
"input_maxYear": "연도까지",
"input_played": "재생 필터",
"input_played_optionAll": "모든 트랙",
"input_played_optionUnplayed": "재생하지 않은 트랙만",
"input_played_optionPlayed": "재생된 트랙만"
}
},
"page": {
@@ -325,7 +401,13 @@
"collapseSidebar": "사이드바 줄이기",
"expandSidebar": "사이드바 확장",
"privateModeOff": "프라이빗 모드 끄기",
"privateModeOn": "프라이빗 모드 켜기"
"privateModeOn": "프라이빗 모드 켜기",
"commandPalette": "명령 팔레트 열기",
"quit": "$t(common.quit)",
"selectMusicFolder": "음악 폴더 선택",
"noMusicFolder": "음악 폴더가 선택되지 않았습니다",
"multipleMusicFolders": "{{count}}개의 음악 폴더가 선택되었습니다",
"settings": "$t(common.setting, {\"count\": 2})"
},
"manageServers": {
"title": "서버 설정하기",
@@ -350,7 +432,9 @@
"lyricGap": "가사 간격",
"lyricSize": "가사 크기",
"showLyricMatch": "가사 일치 표시",
"showLyricProvider": "가사 제공자 표시"
"showLyricProvider": "가사 제공자 표시",
"lyricOpacityNonActive": "비활성 가사 불투명도",
"lyricScaleNonActive": "비활성 서정적 척도"
},
"lyrics": "가사",
"related": "관련",
@@ -364,7 +448,27 @@
"shareItem": "공유",
"goToAlbum": "$t(entity.album, {\"count\": 1})으로 이동",
"goToAlbumArtist": "$t(entity.albumArtist, {\"count\": 1})으로 이동",
"showDetails": "추가정보"
"showDetails": "추가정보",
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"goTo": "이동"
},
"albumArtistDetail": {
"about": "{{artist}}에 대해",
@@ -375,7 +479,13 @@
"topSongs": "최고의 곡들",
"topSongsFrom": "{{title}}이 포함된 최고의 곡들",
"viewAll": "전부 보이기",
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기"
"viewAllTracks": "$t(entity.track, {\"count\": 2}) 전부 보이기",
"favoriteSongs": "좋아하는 노래들",
"groupingTypeAll": "모든 릴리스 유형",
"groupingTypePrimary": "주요 릴리스 유형",
"topSongsCommunity": "공동체",
"topSongsPersonal": "개인의",
"favoriteSongsFrom": "{{title}}에서 가장 좋아하는 곡들"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -386,11 +496,14 @@
"released": "발매"
},
"albumList": {
"artistAlbums": "{{artist}}의 앨범"
"artistAlbums": "{{artist}}의 앨범",
"genreAlbums": "\"{{genre}}\" $t(entity.album, {\"count\": 2})",
"title": "$t(entity.album, {\"count\": 2})"
},
"genreList": {
"showAlbums": "$t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2}) 표시",
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시"
"showTracks": "$t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2}) 표시",
"title": "$t(entity.genre, {\"count\": 2})"
},
"globalSearch": {
"commands": {
@@ -405,7 +518,9 @@
"mostPlayed": "자주 플레이된 곡",
"newlyAdded": "최근에 추가된 곡",
"recentlyPlayed": "최근에 플레이된 곡",
"recentlyReleased": "최근에 발매된 곡"
"recentlyReleased": "최근에 발매된 곡",
"genres": "$t(entity.genre, {\"count\": 2})",
"title": "$t(common.home)"
},
"itemDetail": {
"copyPath": "클립보드에 경로를 복사",
@@ -420,15 +535,71 @@
"generalTab": "일반",
"hotkeysTab": "단축키",
"playbackTab": "재생",
"windowTab": "윈도우"
"windowTab": "윈도우",
"analytics": "해석학",
"updates": "업데이트",
"cache": "은닉처",
"application": "애플리케이션",
"queryBuilder": "쿼리 빌더",
"theme": "테마",
"controls": "통제 수단",
"sidebar": "사이드바",
"exportImport": "가져오기/내보내기",
"audio": "오디오",
"lyrics": "가사",
"lyricsDisplay": "가사 표시",
"transcoding": "트랜스코딩",
"discord": "Discord",
"logger": "로거",
"playerFilters": "선수 필터"
},
"sidebar": {
"myLibrary": "내 라이브러리",
"nowPlaying": "재생중",
"shared": "공유 $t(entity.playlist, {\"count\": 2})"
"shared": "공유 $t(entity.playlist, {\"count\": 2})",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"collections": "컬렉션",
"artists": "$t(entity.artist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"folders": "$t(entity.folder, {\"count\": 2})",
"genres": "$t(entity.genre, {\"count\": 2})",
"home": "$t(common.home)",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})"
},
"trackList": {
"artistTracks": "{{artist}}의 음악"
"artistTracks": "{{artist}}의 음악",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"radioList": {
"title": "라디오 방송국"
},
"releasenotes": {
"commitsSinceStable": "{{stable}} 이후 커밋",
"noNewCommits": "이 범위에 새로운 커밋이 없습니다",
"noStableReleaseToCompare": "비교할 수 있는 안정화 릴리스가 없습니다"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"windowBar": {
"paused": "(일시 정지됨) ",
"privateMode": "(비공개 모드)"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "기존 항목 덮어쓰기",
"saveAsCollection": "컬렉션으로 저장"
}
},
"table": {
@@ -473,7 +644,25 @@
"toggleFullscreenPlayer": "전체화면으로 전환",
"unfavorite": "즐겨찾기 취소",
"pause": "멈춤",
"viewQueue": "대기열 보기"
"viewQueue": "대기열 보기",
"addLastShuffled": "마지막 (섞인)",
"addNextShuffled": "다음 (무작위)",
"albumRadio": "앨범 라디오",
"artistRadio": "아티스트 라디오",
"holdToShuffle": "길게 눌러 섞기",
"lyrics": "가사",
"restoreQueueFromServer": "서버에서 큐 복원",
"saveQueueToServer": "대기열을 서버에 저장",
"trackRadio": "라디오 추적",
"sleepTimer": "취침 타이머",
"sleepTimer_endOfSong": "현재 곡 종료",
"sleepTimer_endOfAlbum": "현재 앨범의 끝",
"sleepTimer_minutes": "{{count}}분",
"sleepTimer_hours": "{{count}}시간",
"sleepTimer_off": "끄다",
"sleepTimer_timeRemaining": "{{time}} 남음",
"sleepTimer_setCustom": "타이머 설정",
"sleepTimer_cancel": "타이머 취소"
},
"setting": {
"accentColor_description": "앱의 강조색상 설정",
@@ -482,7 +671,7 @@
"albumBackground": "앨범 배경이미지",
"albumBackgroundBlur_description": "앨범 배경이미지의 흐려짐 정도 조정",
"albumBackgroundBlur": "앨범배경이미지 흐려짐 크기",
"applicationHotkeys_description": "앱의 단축키 설정. 앱 전체에 적용되는 단축키 설정하기 위해서는 체크박스에 체크하세요(PC만 가능)",
"applicationHotkeys_description": "애플리케이션 단축키 설정합니다. 체크박스를 전환하여 전역 단축키 설정하세요(데스크톱 전용)",
"applicationHotkeys": "앱 단축키",
"artistBackground": "아티스트 배경이미지",
"artistBackground_description": "아티스트 페이지에 아티스트가 포함된 배경이미지를 추가",
@@ -492,7 +681,7 @@
"artistConfiguration_description": "앨범아티스트 페이지에 표시할 정보 및 순서 설정",
"audioDevice_description": "음악재생에 사용할 장치 선택(웹플레이어만 가능)",
"audioDevice": "오디오 장치",
"audioExclusiveMode_description": "단독재생모드 켜기. 이 모드에서는 일반적으로 시스템의 재생장치가 고정되며 MPV로만 오디오가 재생됩니다",
"audioExclusiveMode_description": "독점 출력 모드를 활성화합니다. 이 모드에서는 일반적으로 시스템의 오디오 출력이 차단되며, 오직 mpv만이 오디오를 출력할 수 있습니다. 이 모드가 활성화된 동안에는 비주얼라이저의 시스템 오디오 캡처 기능이 작동하지 않습니다",
"audioExclusiveMode": "오디오 단독재생모드",
"audioPlayer_description": "재생을 위한 오디오 플레이어 선택",
"audioPlayer": "오디오 플레이어",
@@ -505,7 +694,8 @@
"broadcast": "방송",
"ep": "ep앨범",
"other": "기타",
"single": "싱글"
"single": "싱글",
"album": "$t(entity.album, {\"count\": 1})"
},
"secondary": {
"audiobook": "오디오북",
@@ -521,5 +711,35 @@
"soundtrack": "사운드트랙",
"spokenWord": "보컬사운드"
}
},
"datetime": {
"minuteShort": "분",
"secondShort": "초",
"hourShort": "시간",
"dayShort": "일"
},
"filterOperator": {
"after": "~ 뒤에 있나요",
"afterDate": "(날짜) 이후입니까",
"before": "~보다 앞서 있다",
"beforeDate": "(날짜) 이전인가요",
"contains": "포함",
"endsWith": "~로 끝남",
"inPlaylist": "~ 안에 있다",
"inTheLast": "마지막에 있습니다",
"inTheRange": "범위 내에 있습니다",
"inTheRangeDate": "범위 내에 있음 (날짜)",
"is": "~이다",
"isNot": "~이 아닙니까",
"isGreaterThan": "~보다 크다",
"isLessThan": "~보다 작다",
"matchesRegex": "정규식과 일치",
"notContains": "함유하지 않음",
"notInPlaylist": "~ 안에 있지 않다",
"notInTheLast": "마지막에 있지 않다",
"startsWith": "~로 시작함"
},
"queryBuilder": {
"customTags": "사용자 정의 태그"
}
}
+25 -4
View File
@@ -3,7 +3,9 @@
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz",
"spotify": "Åpne i Spotify"
"spotify": "Åpne i Spotify",
"listenbrainz": "Åpne i ListenBrainz",
"qobuz": "Åpne i Qobuz"
},
"moveToBottom": "Flytt til bunnen",
"deletePlaylist": "Slett $t(entity.playlist, {\"count\": 1})",
@@ -38,7 +40,10 @@
"shuffleAll": "Tilfelding avspilling av alt",
"shuffleSelected": "Tilfelding avspilling av utvalgte",
"viewMore": "Se mer",
"openApplicationDirectory": "Åpne applikasjonskatalogen"
"openApplicationDirectory": "Åpne applikasjonskatalogen",
"goToCurrent": "Gå til gjeldende element",
"collapseAllFolders": "Skjul alle mapper",
"expandAllFolders": "Utvid alle mapper"
},
"common": {
"bpm": "Bpm",
@@ -161,7 +166,11 @@
"tableColumns": "Tabellkolonner",
"itemsMore": "{{count}} fler",
"explicitStatus": "Grovhetsstatus",
"newVersionAvailable": "En ny version er tilgjengelig"
"newVersionAvailable": "En ny version er tilgjengelig",
"back": "Tilbake",
"openFolder": "Åpne mappe",
"grouping": "Grupper",
"numberOfResults": "{{numberOfResults}} resultater"
},
"entity": {
"smartPlaylist": "Smart $t(entity.playlist, {\"count\": 1})",
@@ -667,7 +676,19 @@
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
"z": "Z",
"none": "Ingen"
},
"frequencyScale": {
"linear": "Lineær skala",
"log": "Logaritmisk skala"
},
"channelLayout": {
"single": "Enkel"
},
"gradient": {
"rainbow": "Regnbue",
"prism": "Prisme"
}
}
}
+44 -29
View File
@@ -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",
@@ -410,7 +410,12 @@
"input_played": "Filtr odtwarzania",
"input_played_optionAll": "Wszystkie utwory",
"input_played_optionUnplayed": "Tylko nieodtworzone utwory",
"input_played_optionPlayed": "Tylko odtworzone utwory"
"input_played_optionPlayed": "Tylko odtworzone utwory",
"input_kind_albums": "Albumy",
"input_kind_songs": "Piosenki",
"input_kind": "Losowy wybór",
"input_limit_albums": "Ile albumów?",
"input_limit_songs": "Ile piosenek?"
},
"saveQueue": {
"success": "Zapisano kolejkę odtwarzania na serwerze"
@@ -695,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",
@@ -974,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ń",
@@ -991,7 +997,7 @@
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
"autoDJ": "Automatyczny DJ",
"autoDJ_itemCount": "Liczba elementów",
"autoDJ_itemCount_description": "Liczba elementów, które będzie próbować dodać do kolejki kiedy automatyczny DJ jest włączony",
"autoDJ_itemCount_description": "Liczba elementów, które będzie próbować dodać do kolejki",
"autoDJ_timing": "Czas dodawania",
"autoDJ_timing_description": "Ilość piosenek pozostałych w kolejce przed tym gdy zostanie włączony automatyczny DJ",
"logLevel": "Poziom logów",
@@ -1093,7 +1099,16 @@
"sidebarPlaylistMode_description": "Jak każda z playlist jest wyświetlana w liście w pasku bocznym",
"sidebarPlaylistMode": "Tryb playlist bocznego paska",
"sidebarPlaylistMode_optionCompact": "Kompaktowy",
"sidebarPlaylistMode_optionExpanded": "Rozszerzony"
"sidebarPlaylistMode_optionExpanded": "Rozszerzony",
"autoDJ_mode": "Tryb",
"autoDJ_mode_albums": "Albumy",
"autoDJ_mode_description": "Wybierz dodawanie piosenek lub całych albumów do kolejki",
"autoDJ_mode_songs": "Piosenki",
"autoDJ_enabled": "Włącz Auto DJ",
"autoDJ_albumStrategy": "Tryb wyboru albumów",
"autoDJ_songStrategy": "Tryb wyboru piosenek",
"autoDJ_strategy_option_library_random": "Losowo",
"autoDJ_strategy_option_similar": "Podobne"
},
"table": {
"config": {
+172 -9
View File
@@ -174,7 +174,8 @@
"explicitStatus": "Признак нецензурного контента",
"newVersionAvailable": "Доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов",
"back": "Назад"
"back": "Назад",
"openFolder": "Открыть папку"
},
"entity": {
"album_one": "Альбом",
@@ -240,7 +241,10 @@
"table": {
"config": {
"view": {
"table": "Таблица"
"table": "Таблица",
"detail": "Детали",
"grid": "Сетка",
"list": "Список"
},
"general": {
"displayType": "Тип отображения",
@@ -250,7 +254,29 @@
"followCurrentSong": "Следовать за исполняемым треком",
"size": "$t(common.size)",
"itemSize": "Размер элементов (px)",
"itemGap": "Отступ между элементами (px)"
"itemGap": "Отступ между элементами (px)",
"advancedSettings": "Расширенные настройки",
"autosize": "Автоматический выбор размера",
"moveUp": "Переместить выше",
"moveDown": "Переместить ниже",
"pinToLeft": "Закрепить слева",
"pinToRight": "Закрепить права",
"alignLeft": "Выровнять по левой стороне",
"alignCenter": "Выровнять по центру",
"alignRight": "Выровнять по правой стороне",
"itemsPerRow": "Элементов в строке",
"size_default": "По-умолчанию",
"size_compact": "Компактный",
"size_large": "Большой",
"pagination": "Пагинация",
"pagination_itemsPerPage": "Элементов на странице",
"pagination_infinite": "Бесконечно",
"pagination_paginate": "Разбитый по страницам",
"alternateRowColors": "Переменный цвет строк",
"horizontalBorders": "Границы строки",
"rowHoverHighlight": "Подсветка строки при наведении",
"showHeader": "Показать заголовок",
"verticalBorders": "Границы колонки"
},
"label": {
"releaseDate": "Дата выхода",
@@ -276,7 +302,10 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"codec": "$t(common.codec)",
"titleArtist": "$t(common.title) (артист)"
"titleArtist": "$t(common.title) (артист)",
"albumGroup": "Группа альбома",
"composer": "Композитор",
"image": "Изображение"
}
},
"column": {
@@ -299,7 +328,14 @@
"comment": "Комментарий",
"bitrate": "Битрейт",
"channels": "$t(common.channel_other)",
"bpm": "BPM"
"bpm": "BPM",
"albumCount": "Альбомы",
"artist": "Исполнители",
"bitDepth": "Битовая глубина",
"genre": "Жанр",
"sampleRate": "Частота дискретизации",
"songCount": "Треки",
"owner": "Правообладатель"
}
},
"error": {
@@ -434,7 +470,8 @@
"sleepTimer_setCustom": "Установить таймер",
"sleepTimer_custom": "Пользовательский",
"sleepTimer_cancel": "Отменить таймер",
"scrobbleForceSubmit": "Принудительная скробблинг"
"scrobbleForceSubmit": "Принудительная скробблинг",
"sleepTimer_endOfAlbum": "Конец этого альбома"
},
"page": {
"sidebar": {
@@ -756,7 +793,12 @@
"input_played_optionAll": "Все треки",
"input_played_optionUnplayed": "Только не игранные треки",
"input_played_optionPlayed": "Только воспроизведённые треки",
"input_genre": "$t(entity.genre, {\"count\": 1})"
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_kind_albums": "Альбомы",
"input_kind_songs": "Песни",
"input_kind": "Случайный выбор",
"input_limit_albums": "Сколько альбомов?",
"input_limit_songs": "Сколько песен?"
},
"editRadioStation": {
"success": "Радиостанция успешно обновлена"
@@ -1087,7 +1129,78 @@
"audioFadeOnStatusChange": "плавное изменение звука",
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
"preventSleepOnPlayback": "не переходить в спящий режим"
"preventSleepOnPlayback": "не переходить в спящий режим",
"autoDJ_mode": "Режим",
"autoDJ_mode_albums": "Альбомы",
"autoDJ_mode_description": "Добавь песни или целые альбомы в очередь",
"autoDJ_mode_songs": "Песни",
"autoDJ_enabled": "Включить Auto DJ",
"autoDJ_albumStrategy": "Режим выбора альбома",
"autoDJ_songStrategy": "Режим выбора песни",
"autoDJ_strategy_option_library_random": "Случайно",
"autoDJ_strategy_option_similar": "Похожие",
"hotkey_listShowPlayingSong": "Показать текущую песню в списке",
"listenbrainz_description": "Показать ссылки на ListenBrains на страницах исполнителя/альбома",
"listenbrainz": "Показать ссылки на ListenBrainz",
"qobuz_description": "Показать ссылки на Qobuz на страницах исполнителя/альбома",
"qobuz": "Показать ссылки на Qobuz",
"spotify_description": "Показать ссылки на Spotify на странице исполнителя/альбома",
"spotify": "Показать ссылки на Spotify",
"nativeSpotify_description": "Открывать в приложении Spotify вместо браузера",
"nativeSpotify": "Использовать приложение Spotify",
"imageResolution_optionTable": "Таблица",
"preventSuspendOnPlayback_description": "Не приостанавливать приложение во время проигрывания музыки",
"preventSuspendOnPlayback": "Не приостанавливать во время проигрывания",
"playerItemConfiguration_description": "Настроить какие элементы и в каком порядке видны в полноэкранном плеере",
"playerItemConfiguration": "Настройка плеера",
"sidebarPlaylistFolders": "Включить папки",
"sidebarPlaylistFolderSeparator_description": "Символ (или строка), который разделяет уровни папок в названии плейлиста",
"sidebarPlaylistFolderSeparator": "Разделитель папок",
"sidebarPlaylistFolderView_description": "Как отображать папки в боковой панели",
"sidebarPlaylistFolderView": "Вид папок",
"sidebarPlaylistFolderView_optionSingle": "Единстванная папка",
"sidebarPlaylistFolderView_optionTree": "Вид дерева",
"sidebarPlaylistFolderView_optionNavigation": "Вид навигации",
"sidebarPlaylistFolderTreeIndent_description": "Отступ в пикселях на каждом уровне дерева",
"sidebarPlaylistFolderTreeIndent": "Отступ в дереве",
"sidebarPlaylistFolderTreeLineColor_description": "Цвет линий соединения в дереве (оставь пустым, чтобы использовать настройки темы)",
"sidebarPlaylistFolderTreeLineColor": "Цвет линии в дереве",
"sidebarPlaylistMode_description": "Как отображать каждый плейлист в списке в боковой панели",
"sidebarPlaylistMode": "Режим плейлиста в боковой панели",
"sidebarPlaylistMode_optionCompact": "Компактный",
"sidebarPlaylistMode_optionExpanded": "Просторный",
"sidebarPlaylistSorting_description": "Разрешить ручную сортировку плейлистов в боковой панели с помощью перетаскивания вместо сортировки со стороны сервера",
"sidebarPlaylistSorting": "Сортировка плейлистов в боковой панели",
"sidebarPlaylistListFilterRegex_description": "Скрывать плейлисты в боковой панели, которые соответствуют этому регулярному выражению",
"sidebarPlaylistListFilterRegex_placeholder": "Например ^daily mix.*",
"sidebarPlaylistListFilterRegex": "Регулярное выражение для фильтрации плейлистов",
"sidePlayQueueLayout": "Макет очереди проигрывания сбоку",
"sidePlayQueueLayout_description": "Задает макет прикрепленной очереди проигрывания сбоку",
"sidePlayQueueLayout_optionHorizontal": "Горизонтальный",
"sidePlayQueueLayout_optionVertical": "Вертикальный",
"mediaSession_description": "Включает интеграцию сессии медиа, отображая элементы управления и метаданные медиа в системном оверлее управления громкостью и на экране блокировки. Требуется Web Audio Player.",
"mediaSession": "Включить сессию медиа",
"skipPlaylistPage_description": "Когда переходишь в плейлист, откроется страница со списком песен плейлиста, вместо страницы по-умолчанию",
"transcode": "Включить транскодирование",
"transcodeFormat_description": "Выбирает форматы для транскодирования. Оставь пустым, чтобы решение принимал сервер",
"translationApiKey_description": "Ключ API для перевода (только эндпойнт глобального сервиса)",
"translationApiKey": "Ключ API перевода",
"translationApiProvider_description": "Поставщик API для перевода",
"translationApiProvider": "Поставщик API перевода",
"translationTargetLanguage_description": "На какой язык выполнять перевод",
"translationTargetLanguage": "На какой язык переводить",
"trayEnabled_description": "Показать/скрыть иконку/меню в трее. Если скрыто, то также отключается сворачивать в трей/свернуть в трей при выходе",
"trayEnabled": "Показать в трее",
"queryBuilder": "Создатель очереди",
"queryBuilderCustomFields_inputLabel": "Метка",
"queryBuilderCustomFields_inputTag": "Тег",
"queryBuilderCustomFields": "Пользовательские поля",
"queryBuilderCustomFields_description": "Добавь пользовательские поля для использования создателями очереди",
"hotkey_listNavigateToPage": "Перейти к странице элемента",
"hotkey_listPlayDefault": "Воспроизвести список",
"hotkey_listPlayLast": "Воспроизвести последний в списке",
"hotkey_listPlayNext": "Воспроизвести следующий в списке",
"sidebarPlaylistFolders_description": "Создать вид папки для плейлистов, которые включают настраиваемый разделитель в имени"
},
"releaseType": {
"secondary": {
@@ -1162,6 +1275,56 @@
"presetName": "Название пресета",
"presetNamePlaceholder": "Введите название пресета",
"general": "Главная",
"lineWidth": "Ширина линии"
"lineWidth": "Ширина линии",
"systemAudioConsentAllow": "Разрешить",
"systemAudioConsentBody": "Для работы визуализатора требуется доступ к аудио в системе",
"systemAudioConsentDecline": "Запретить",
"systemAudioConsentTitle": "Разрешить доступ к аудио в системе?",
"systemAudioCaptureFailed": "Не удается начать захват: {{message}}",
"visualizerType": "Тип визуализатора",
"cyclePresets": "Переключаться между наборами настроек",
"cycleTime": "Время между переключениями (в секундах)",
"includeAllPresets": "Включить все наборы настроек",
"ignoredPresets": "Игнорируемые наборы настроек",
"selectedPresets": "Выбранные наборы настроек",
"randomizeNextPreset": "Выбирать следующий набор настроек случайным образом",
"blendTime": "Время смешивания",
"mode": "Режим",
"mode1To8": "Режимы 1-8",
"mode10": "Режим 10",
"maxFPS": "Максимум кадров в секунду",
"opacity": "Прозрачность",
"customGradients": "Пользовательские градиенты",
"addCustomGradient": "Добавить пользовательский градиент",
"gradientName": "Название градиента",
"gradientNamePlaceholder": "Название градиента",
"vertical": "Вертикальный",
"horizontal": "Горизонтальный",
"addColor": "Добавить цвет",
"position": "Расположение",
"level": "Уровень",
"remove": "Удалить",
"pasteGradient": "Вставить градиент",
"pasteGradientPlaceholder": "Вставить JSON с градиентом сюда...",
"custom": "Пользовательский",
"builtIn": "Встроенный",
"colors": "Цвета",
"colorMode": "Цветовой режим",
"gradient": "Градиент",
"gradientLeft": "Градиент слева",
"gradientRight": "Градиент справа",
"smoothing": "Сглаживание",
"minimumFrequency": "Минимальная частота",
"maximumFrequency": "Максимальная частота",
"sensitivity": "Чуствительность",
"minimumDecibels": "Минимум децибел",
"maximumDecibels": "Максимум децибел",
"linearAmplitude": "Линейная амплитуда",
"showPeaks": "Показывать пики"
},
"dragDropZone": {
"error_oneFileOnly": "Выбери только 1 файл",
"error_readingFile": "Проблема при чтении файла: {{errorMessage}}",
"mainText": "Перемести файл сюда"
}
}
+14
View File
@@ -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"
}
}
+2 -1
View File
@@ -169,7 +169,8 @@
"tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше",
"numberOfResults": "{{numberOfResults}} результатів",
"newVersionAvailable": "Доступна нова версія"
"newVersionAvailable": "Доступна нова версія",
"back": "Повернутися"
},
"entity": {
"album_one": "Альбом",
+99 -84
View File
@@ -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": "管理",
@@ -40,14 +40,14 @@
"owner": "所有者",
"path": "路徑",
"playerMustBePaused": "播放器必須先暫停",
"previousSong": "上一首$t(entity.track, {\"count\": 1})",
"previousSong": "上一首 $t(entity.track, {\"count\": 1})",
"quit": "退出",
"random": "隨機",
"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,9 +105,9 @@
"clean": "清除",
"explicitStatus": "露骨狀態",
"explicit": "露骨",
"gridRows": "網格",
"noFilters": "未設定任何過濾器",
"countSelected": "{{count}}個已選取",
"gridRows": "網格",
"noFilters": "未配置篩選器",
"countSelected": "{{count}} 個已選取",
"retry": "重試",
"example": "範例",
"mood": "情緒",
@@ -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,11 +713,11 @@
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
"followCurrentSong": "跟隨當前歌曲",
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
"playerFilters": "從佇列中過濾歌曲",
"playerFilters": "從佇列中篩選歌曲",
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
"autoDJ": "Auto DJ",
"autoDJ_itemCount": "歌曲數量",
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
"autoDJ_itemCount": "項目數量",
"autoDJ_itemCount_description": "嘗試加入佇列的項目數量",
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
"autoDJ_timing": "觸發時機",
"logLevel": "Log等級",
@@ -763,7 +764,7 @@
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"sidebarPlaylistListFilterRegex": "播放清單篩選器正規表達式",
"blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
@@ -818,7 +819,16 @@
"sidebarPlaylistMode_description": "各播放清單在側邊欄列表中的顯示方式",
"sidebarPlaylistMode": "側邊欄播放清單模式",
"sidebarPlaylistMode_optionCompact": "緊湊",
"sidebarPlaylistMode_optionExpanded": "展開"
"sidebarPlaylistMode_optionExpanded": "展開",
"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": "相似"
},
"table": {
"config": {
@@ -878,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)",
@@ -921,36 +931,36 @@
"bpm": "BPM",
"songCount": "曲目",
"title": "標題",
"trackNumber": "曲目編號",
"trackNumber": "曲目",
"size": "大小",
"codec": "編",
"codec": "編解碼器",
"owner": "擁有者",
"bitDepth": "位元深度",
"sampleRate": "取樣率"
}
},
"action": {
"addToFavorites": "新增$t(entity.favorite, {\"count\": 2})",
"clearQueue": "清空播放佇列",
"createPlaylist": "建立$t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "刪除$t(entity.playlist, {\"count\": 1})",
"addToPlaylist": "新增$t(entity.playlist, {\"count\": 1})",
"addToFavorites": "新增$t(entity.favorite, {\"count\": 2})",
"clearQueue": "清空佇列",
"createPlaylist": "建立 $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "刪除 $t(entity.playlist, {\"count\": 1})",
"addToPlaylist": "新增$t(entity.playlist, {\"count\": 1})",
"deselectAll": "取消全選",
"editPlaylist": "編輯 $t(entity.playlist, {\"count\": 1})",
"goToPage": "前往頁面",
"moveToBottom": "移至底部",
"moveToTop": "移至頂部",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "從$t(entity.favorite, {\"count\": 2})移除",
"removeFromPlaylist": "從$t(entity.playlist, {\"count\": 1})移除",
"removeFromQueue": "從播放佇列中移除",
"removeFromFavorites": "從 $t(entity.favorite, {\"count\": 2}) 移除",
"removeFromPlaylist": "從 $t(entity.playlist, {\"count\": 1}) 移除",
"removeFromQueue": "從佇列中移除",
"setRating": "評分",
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
"viewPlaylists": "查看$t(entity.playlist, {\"count\": 2})",
"toggleSmartPlaylistEditor": "切換 $t(entity.smartPlaylist) 編輯器",
"viewPlaylists": "查看 $t(entity.playlist, {\"count\": 2})",
"moveToNext": "移至下一項",
"openIn": {
"lastfm": "在Last.fm開啟",
"musicbrainz": "在MusicBrainz開啟",
"lastfm": "在 Last.fm開啟",
"musicbrainz": "在 MusicBrainz 開啟",
"spotify": "在 Spotify 中開啟",
"listenbrainz": "在 ListenBrainz 中開啟",
"qobuz": "在 Qobuz 中開啟"
@@ -988,19 +998,19 @@
"genreWithCount_other": "{{count}} 種曲風",
"playlist_other": "播放清單",
"playlistWithCount_other": "{{count}} 個播放清單",
"smartPlaylist": "智慧$t(entity.playlist, {\"count\": 1})",
"smartPlaylist": "智慧 $t(entity.playlist, {\"count\": 1})",
"track_other": "曲目",
"trackWithCount_other": "{{count}} 曲目",
"trackWithCount_other": "{{count}} 曲目",
"albumWithCount_other": "{{count}} 張專輯",
"play_other": "{{count}}次播放",
"play_other": "{{count}} 次播放",
"song_other": "歌曲",
"radioStation_other": "電台",
"radioStationWithCount_other": "{{count}} 個電台"
},
"filter": {
"albumCount": "$t(entity.album, {\"count\": 2})數",
"albumCount": "$t(entity.album, {\"count\": 2}) 數",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "個人簡介",
"biography": "簡介",
"bitrate": "位元率",
"bpm": "BPM",
"channels": "$t(common.channel, {\"count\": 2})",
@@ -1013,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": "路徑",
@@ -1038,7 +1048,7 @@
"releaseYear": "發行年份",
"search": "搜尋",
"title": "標題",
"toYear": "年份",
"toYear": "年份",
"trackNumber": "曲目",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "排序名稱",
@@ -1092,7 +1102,7 @@
"title": "查詢編輯器",
"addRuleGroup": "新增規則群組",
"removeRuleGroup": "移除規則群組",
"resetToDefault": "恢復為預設",
"resetToDefault": "重置為預設",
"clearFilters": "清除篩選"
},
"updateServer": {
@@ -1133,11 +1143,16 @@
"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": "僅播放過的曲目"
"input_played_optionPlayed": "僅播放過的曲目",
"input_kind_albums": "專輯",
"input_kind_songs": "歌曲",
"input_kind": "隨機選取",
"input_limit_albums": "專輯數量?",
"input_limit_songs": "歌曲數量?"
},
"createRadioStation": {
"success": "電台建立成功",
@@ -1366,7 +1381,7 @@
}
},
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioNoAudioTrack": "沒有回傳任何曲目。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
+1 -1
View File
@@ -72,7 +72,7 @@ export const orderSearchResults = (args: {
searchResults = Array.from(combinedResults.values());
} else {
searchResults = fuse.search<InternetProviderLyricSearchResponse>({
searchResults = fuse.search({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
+7 -1
View File
@@ -120,8 +120,14 @@ const createMpv = async (data: {
}): Promise<MpvAPI> => {
const { binaryPath, extraParameters, properties } = data;
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
const normalizedExtraParameters = (extraParameters ?? [])
.map((param) => param.trim())
.filter((param) => param.length > 0);
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
const params = uniq([
...DEFAULT_MPV_PARAMETERS(normalizedExtraParameters),
...normalizedExtraParameters,
]);
const mpv = new MpvAPI(
{
+8
View File
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
}
});
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
if (data.value === null) {
store.delete(data.property);
} else {
store.set(data.property, data.value);
}
});
ipcMain.handle('password-get', (_event, server: string): null | string => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
+61 -12
View File
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
protocol.registerSchemesAsPrivileged([
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
]);
process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error);
@@ -335,7 +337,7 @@ if (isDevelopment) {
}
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
? path.join(path.dirname(app.getAppPath()), 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
}
});
const FONT_HEADERS = [
const FONT_HEADERS = new Set([
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
];
]);
const bytesToInt = (array: Uint8Array, length: number): number => {
let value = 0;
for (let i = 0; i < length; i++) {
value = (value << 8) + array[i];
}
return value;
};
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
0x4f54544f, // font/otf
0x774f4632, // font/woff2
0x774f4646, // font/woff
]);
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
0x0001000000, // ttf, collection, sfnt
]);
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
app.whenReady()
.then(() => {
protocol.handle('feishin', async (request) => {
const filePath = `file:${request.url.slice('feishin:'.length)}`;
const response = await net.fetch(filePath);
const contentType = response.headers.get('content-type');
if (!contentType || !FONT_HEADERS.includes(contentType)) {
protocol.handle('feishin', async () => {
const filePath = store.get('local_font_path');
if (typeof filePath !== 'string') {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
});
}
return response;
const response = await net.fetch('file:' + filePath);
const contentType = response.headers.get('content-type');
// On Linux, the mime type is included in the response header
// In this case, we can forward the response with no further processing
if (contentType && FONT_HEADERS.has(contentType)) {
return response;
}
// Otherwise, let's check the magic number to see if
// the file is a font type. This is either four or five bytes
const payload = await response.arrayBuffer();
const magicNumber = new Uint8Array(payload.slice(0, 5));
const fiveHex = bytesToInt(magicNumber, 5);
const fourHex = bytesToInt(magicNumber, 4);
if (
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
) {
// We have to create a new response with the payload, since it has been read now
return new Response(payload, {
headers: response.headers,
});
}
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
@@ -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';",
],
},
});
+8
View File
@@ -9,6 +9,13 @@ const set = (
ipcRenderer.send('settings-set', { property, value });
};
const setSync = async (
property: string,
value: boolean | null | Record<string, unknown> | string | string[],
) => {
return ipcRenderer.invoke('settings-set-sync', { property, value });
};
const get = async (property: string) => {
return ipcRenderer.invoke('settings-get', { property });
};
@@ -99,6 +106,7 @@ export const localSettings = {
passwordSet,
restart,
set,
setSync,
setZoomFactor,
themeSet,
};
+1
View File
@@ -139,6 +139,7 @@ export const utils = {
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
separator: isWindows() ? '\\' : '/',
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
+36
View File
@@ -54,6 +54,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deleteArtistImage,
400: jfType._response.error,
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
@@ -63,6 +72,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deletePlaylistImage,
400: jfType._response.error,
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
@@ -356,6 +374,24 @@ export const contract = c.router({
400: jfType._response.error,
},
},
uploadArtistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadArtistImage,
400: jfType._response.error,
},
},
uploadPlaylistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadPlaylistImage,
400: jfType._response.error,
},
},
});
const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
import {
albumArtistListSortMap,
albumListSortMap,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
Folder,
genreListSortMap,
ImageArgs,
@@ -29,6 +34,10 @@ import {
SortOrder,
sortOrderMap,
Tag,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const getImageContentType = (bytes: Uint8Array): string => {
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
return 'image/png';
}
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
return 'image/jpeg';
}
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
return 'image/gif';
}
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
return 'image/webp';
}
return 'image/jpeg';
};
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
const deleteItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
errorMessage: string,
): Promise<boolean> => {
const res = await jfApiClient({
...apiClientProps,
server: apiClientProps.server ?? null,
}).deleteArtistImage({
params: {
id,
},
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
const uploadItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
image: Uint8Array,
errorMessage: string,
): Promise<boolean> => {
const server = apiClientProps.server;
const serverUrl = getServerUrl(server);
if (!serverUrl) {
throw new Error('Server is required');
}
const contentType = getImageContentType(image);
const base64 = uint8ArrayToBase64(image);
const authHeader = createAuthHeader();
const authorization = server?.credential
? authHeader.concat(`, Token="${server.credential}"`)
: authHeader;
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
},
signal: apiClientProps.signal,
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
// Limit the query to 50 at a time to be *extremely* conservative on the
// length of the full URL, since the ids are part of the query string and
// not the POST body
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
[ServerFeature.PUBLIC_PLAYLIST]: [1],
},
],
['10.0.0', { [ServerFeature.TAGS]: [1] }],
[
'10.0.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
[ServerFeature.TAGS]: [1],
},
],
];
const JF_FIELDS = {
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
id: res.body.Id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
},
deleteFavorite: async (args) => {
const { apiClientProps, query } = args;
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
},
getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -411,8 +527,12 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get album detail');
}
// Workaround for Jellyfin bug that returns items that share the same album name
const albumIdSet = new Set([query.id]);
const songs = songsRes.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
return jfNormalize.album(
{ ...res.body, Songs: songsRes.body.Items },
{ ...res.body, Songs: songs },
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
@@ -1673,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,
@@ -1843,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,39 +2321,54 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to scrobble');
}
return null;
if (query.submission) {
return null;
}
}
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
const defaultParams = {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
};
const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => {
return ssApiClient(apiClientProps).reportPlayback({
query: {
...defaultParams,
state,
},
});
};
const promises: Promise<any>[] = [];
switch (query.event) {
case 'pause':
state = 'paused';
promises.push(reportPlayback('paused'));
break;
case 'start':
state = 'starting';
promises.push(reportPlayback('starting'));
promises.push(reportPlayback('playing'));
break;
case 'stop':
promises.push(reportPlayback('stopped'));
break;
case 'timeupdate':
case 'unpause':
state = 'playing';
promises.push(reportPlayback('playing'));
break;
default:
state = 'playing';
break;
}
const res = await ssApiClient(apiClientProps).reportPlayback({
query: {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
state,
},
});
for (const promise of promises) {
const res = await promise;
if (res.status !== 200) {
throw new Error('Failed to report playback');
if (res.status !== 200) {
throw new Error('Failed to report playback');
}
}
return null;
@@ -13,7 +13,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
export const getListQueryKeyName = (itemType: LibraryItem): string => {
switch (itemType) {
@@ -108,8 +108,19 @@ export const useItemListInfiniteLoader = ({
[serverId, itemType, query],
);
const isRandomSort = query?.sortBy === SortKeyRandom;
const fetchPage = useCallback(
async (pageNumber: number) => {
if (isRandomSort) {
const existingData =
queryClient.getQueryData<InfiniteLoaderCacheData>(dataQueryKey);
if (existingData?.pagesLoaded?.[pageNumber]) {
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
return;
}
}
const startIndex = pageNumber * itemsPerPage;
const queryParams = {
limit: itemsPerPage,
@@ -118,6 +129,7 @@ export const useItemListInfiniteLoader = ({
};
const result = await queryClient.fetchQuery({
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
queryFn: async ({ signal }) => {
const result = await listQueryFn({
apiClientProps: { serverId, signal },
@@ -127,6 +139,7 @@ export const useItemListInfiniteLoader = ({
return result;
},
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
});
// Update the query data with the fetched page
@@ -154,13 +167,32 @@ export const useItemListInfiniteLoader = ({
// Track the last fetched page
lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber);
},
[itemsPerPage, query, queryClient, serverId, dataQueryKey, listQueryFn, itemType],
[
itemsPerPage,
query,
queryClient,
serverId,
dataQueryKey,
listQueryFn,
itemType,
isRandomSort,
],
);
// Reset the loaded pages and refetch current page when the query changes
useEffect(() => {
const currentDataQueryKey = JSON.stringify(dataQueryKey);
if (isRandomSort) {
const existingData = queryClient.getQueryData<InfiniteLoaderCacheData | undefined>(
dataQueryKey,
);
if (existingData?.dataMap && existingData.dataMap.size > 0) {
previousDataQueryKeyRef.current = currentDataQueryKey;
return;
}
}
if (previousDataQueryKeyRef.current === currentDataQueryKey || isRefetchingRef.current) {
return;
}
@@ -12,7 +12,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { getListRefreshMutationKey } from '/@/renderer/features/shared/components/list-refresh-button';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, SortKeyRandom } from '/@/shared/types/domain-types';
const getQueryKeyName = (itemType: LibraryItem): string => {
switch (itemType) {
@@ -76,6 +76,8 @@ export const useItemListPaginatedLoader = ({
const fetchRange = getFetchRange(currentPage, itemsPerPage);
const startIndex = fetchRange.startIndex;
const isRandomSort = query?.sortBy === SortKeyRandom;
const queryParams = useMemo(
() => ({
limit: itemsPerPage,
@@ -86,7 +88,7 @@ export const useItemListPaginatedLoader = ({
);
const { data } = useQuery({
gcTime: 1000 * 15,
gcTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
placeholderData: { items: getInitialData(itemsPerPage) },
queryFn: async ({ signal }) => {
const result = await listQueryFn({
@@ -97,7 +99,7 @@ export const useItemListPaginatedLoader = ({
return result;
},
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
staleTime: 1000 * 15,
staleTime: isRandomSort ? 1000 * 60 * 10 : 1000 * 15,
});
const refreshMutation = useMutation({
@@ -44,6 +44,7 @@ import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list
import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column';
import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column';
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
@@ -239,10 +240,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
case TableColumn.CHANNELS:
case TableColumn.DISC_NUMBER:
case TableColumn.SAMPLE_RATE:
case TableColumn.TRACK_NUMBER:
return (
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
);
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.COMPOSER:
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -304,6 +302,11 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
/>
);
case TableColumn.TRACK_NUMBER:
return (
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.USER_FAVORITE:
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -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) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.id,
};
} else if (firstItem._itemType === LibraryItem.SONG) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.albumId,
};
} else if (
firstItem._itemType === LibraryItem.ARTIST ||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
) {
return {
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
albumId: null,
};
switch (firstItem._itemType) {
case LibraryItem.ALBUM:
return {
albumId: firstItem.id,
artists: firstItem.albumArtists || [],
};
case LibraryItem.SONG:
return {
albumId: firstItem.albumId,
artists:
(firstItem.artists?.length ? firstItem.artists : firstItem.albumArtists) ||
[],
};
default:
return {
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>
);
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '/@/renderer/api';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import {
useIsRadioActive,
useRadioPlayer,
@@ -36,6 +37,7 @@ const DiscordStatusDisplayType = {
} as const;
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
type ActivityTrigger = 'initial' | 'interval' | 'seek' | 'status_change' | 'track_change';
const MAX_FIELD_LENGTH = 127;
const MAX_URL_LENGTH = 256;
@@ -64,22 +66,24 @@ export const useDiscordRpc = () => {
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const previousActivityStateRef = useRef<ActivityState | null>(null);
const discordEnabledRef = useRef<boolean>(discordSettings.enabled);
const privateModeRef = useRef<boolean>(privateMode);
// Update imageUrl ref when it changes
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
useEffect(() => {
discordEnabledRef.current = discordSettings.enabled;
}, [discordSettings.enabled]);
useEffect(() => {
privateModeRef.current = privateMode;
}, [privateMode]);
const setActivity = useCallback(
async (current: ActivityState, previous: ActivityState) => {
// Check if track changed by comparing with previous state
async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0];
const previousSong = previous[0];
const trackChangedByState =
song && previousSong
? song._uniqueId !== previousSong._uniqueId
: song !== previousSong;
const trackChanged = song ? lastUniqueId !== song._uniqueId : false;
const isPlayingRadio = isRadioActive && isRadioPlaying;
@@ -103,6 +107,7 @@ export const useDiscordRpc = () => {
meta: {
reason,
status: current[2],
trigger,
},
});
return discordRpc?.clearActivity();
@@ -152,6 +157,7 @@ export const useDiscordRpc = () => {
showAsListening: discordSettings.showAsListening,
stationName: stationName || 'Radio',
title,
trigger,
},
});
discordRpc?.setActivity(activity);
@@ -162,214 +168,177 @@ export const useDiscordRpc = () => {
return;
}
/*
1. If the song has just started, update status
2. If we jump more then 1.2 seconds from last state, update status to match
3. If the current song id is completely different, update status
4. If the player state changed, update status
*/
if (trackChanged) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
category: LogCategory.EXTERNAL,
meta: {
artistName: song.artists?.[0]?.name,
songId: song._uniqueId,
songName: song.name,
},
});
setlastUniqueId(song._uniqueId);
}
const reason = trigger;
const start = Math.round(Date.now() - current[1] * 1000);
const end = Math.round(start + song.duration);
const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
};
const activity: SetActivity = {
details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'),
instance: false,
largeImageKey: undefined,
largeImageText: truncate(
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
),
smallImageKey: undefined,
smallImageText: undefined,
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
statusDisplayType: statusDisplayMap[discordSettings.displayType],
// I would love to use the actual type as opposed to hardcoding to 2,
// but manually installing the discord-types package appears to break things
type: discordSettings.showAsListening ? 2 : 0,
};
if (
previous[1] === 0 ||
Math.abs(current[1] - previous[1]) > 1.2 ||
trackChangedByState ||
trackChanged ||
current[2] !== previous[2]
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
song?.artistName
) {
if (trackChangedByState || trackChanged) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
category: LogCategory.EXTERNAL,
meta: {
artistName: song.artists?.[0]?.name,
songId: song._uniqueId,
songName: song.name,
},
});
setlastUniqueId(song._uniqueId);
activity.stateUrl =
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
const detailsUrl =
'https://www.last.fm/music/' +
encodeURIComponent(song.albumArtists[0].name) +
'/' +
encodeURIComponent(song.album || '_') +
'/' +
encodeURIComponent(song.name);
// The details URL has a max length, only set it if it doesn't exceed it
if (detailsUrl.length <= MAX_URL_LENGTH) {
activity.detailsUrl = detailsUrl;
}
}
if (
discordSettings.linkType == DiscordLinkType.MBZ ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
) {
if (song?.mbzTrackId) {
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
} else if (song?.mbzRecordingId) {
activity.detailsUrl =
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
}
}
if (current[2] === PlayerStatus.PLAYING) {
if (start && end) {
activity.startTimestamp = start;
activity.endTimestamp = end;
}
let reason: string;
if (trackChangedByState || trackChanged) {
reason = 'track_changed';
} else if (previous[1] === 0) {
reason = 'song_started';
} else if (Math.abs(current[1] - previous[1]) > 1.2) {
reason = 'time_jump';
} else {
reason = 'player_state_changed';
}
const start = Math.round(Date.now() - current[1] * 1000);
const end = Math.round(start + song.duration);
const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
};
const activity: SetActivity = {
details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'),
instance: false,
largeImageKey: undefined,
largeImageText: truncate(
(song?.album && song.album.padEnd(2, ' ')) || 'Unknown album',
),
smallImageKey: undefined,
smallImageText: undefined,
state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'),
statusDisplayType: statusDisplayMap[discordSettings.displayType],
// I would love to use the actual type as opposed to hardcoding to 2,
// but manually installing the discord-types package appears to break things
type: discordSettings.showAsListening ? 2 : 0,
};
if (
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
song?.artistName
) {
activity.stateUrl =
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
const detailsUrl =
'https://www.last.fm/music/' +
encodeURIComponent(song.albumArtists[0].name) +
'/' +
encodeURIComponent(song.album || '_') +
'/' +
encodeURIComponent(song.name);
// The details URL has a max length, only set it if it doesn't exceed it
if (detailsUrl.length <= MAX_URL_LENGTH) {
activity.detailsUrl = detailsUrl;
}
}
if (
discordSettings.linkType == DiscordLinkType.MBZ ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
) {
if (song?.mbzTrackId) {
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
} else if (song?.mbzRecordingId) {
activity.detailsUrl =
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
}
}
if (current[2] === PlayerStatus.PLAYING) {
if (start && end) {
activity.startTimestamp = start;
activity.endTimestamp = end;
}
if (discordSettings.showStateIcon) {
activity.smallImageKey = 'playing';
activity.smallImageText = sentenceCase(current[2]);
}
} else {
activity.smallImageKey = 'paused';
if (discordSettings.showStateIcon) {
activity.smallImageKey = 'playing';
activity.smallImageText = sentenceCase(current[2]);
}
} else {
activity.smallImageKey = 'paused';
activity.smallImageText = sentenceCase(current[2]);
}
if (discordSettings.showServerImage && song) {
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
if (song._serverType === ServerType.JELLYFIN) {
activity.largeImageKey = imageUrlRef.current;
} else if (
song._serverType === ServerType.NAVIDROME ||
song._serverType === ServerType.SUBSONIC
) {
try {
const info = await api.controller.getAlbumInfo({
apiClientProps: {
forceRemoteUrl: true,
serverId: song._serverId,
},
query: { id: song.albumId },
});
if (discordSettings.showServerImage && song) {
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
if (song._serverType === ServerType.JELLYFIN) {
activity.largeImageKey = imageUrlRef.current;
} else if (
song._serverType === ServerType.NAVIDROME ||
song._serverType === ServerType.SUBSONIC
) {
try {
const info = await api.controller.getAlbumInfo({
apiClientProps: {
forceRemoteUrl: true,
serverId: song._serverId,
},
query: { id: song.albumId },
});
if (info.imageUrl) {
activity.largeImageKey = info.imageUrl;
}
} catch {
/* empty */
if (info.imageUrl) {
activity.largeImageKey = info.imageUrl;
}
} catch {
/* empty */
}
}
}
if (
activity.largeImageKey === undefined &&
lastfmApiKey &&
song?.album &&
song?.albumArtists.length
) {
const albumInfo = await fetch(
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,
);
const albumInfoJson = await albumInfo.json();
if (albumInfoJson.album?.image?.[3]['#text']) {
activity.largeImageKey = albumInfoJson.album.image[3]['#text'];
}
}
// Fall back to default icon if not set
if (!activity.largeImageKey) {
activity.largeImageKey = 'icon';
}
// Initialize if needed
const isConnected = await discordRpc?.isConnected();
if (!isConnected) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {
category: LogCategory.EXTERNAL,
meta: {
clientId: discordSettings.clientId,
},
});
previousEnabledRef.current = true;
await discordRpc?.initialize(discordSettings.clientId);
}
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {
category: LogCategory.EXTERNAL,
meta: {
albumName: song.album,
artistName: song.artists?.[0]?.name,
currentStatus: current[2],
currentTime: current[1],
displayType: discordSettings.displayType,
hasLargeImage: !!activity.largeImageKey,
hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),
previousStatus: previous[2],
previousTime: previous[1],
reason,
showAsListening: discordSettings.showAsListening,
songName: song.name,
trackChanged: trackChangedByState || trackChanged,
},
});
discordRpc?.setActivity(activity);
} else {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, {
category: LogCategory.EXTERNAL,
meta: {
currentStatus: current[2],
currentTime: current[1],
previousStatus: previous[2],
previousTime: previous[1],
timeDiff: Math.abs(current[1] - previous[1]),
trackChanged: trackChangedByState || trackChanged,
},
});
}
if (
activity.largeImageKey === undefined &&
lastfmApiKey &&
song?.album &&
song?.albumArtists.length
) {
const albumInfo = await fetch(
`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`,
);
const albumInfoJson = await albumInfo.json();
if (albumInfoJson.album?.image?.[3]['#text']) {
activity.largeImageKey = albumInfoJson.album.image[3]['#text'];
}
}
// Fall back to default icon if not set
if (!activity.largeImageKey) {
activity.largeImageKey = 'icon';
}
// Initialize if needed
const isConnected = await discordRpc?.isConnected();
if (!isConnected) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, {
category: LogCategory.EXTERNAL,
meta: {
clientId: discordSettings.clientId,
},
});
previousEnabledRef.current = true;
await discordRpc?.initialize(discordSettings.clientId);
}
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, {
category: LogCategory.EXTERNAL,
meta: {
albumName: song.album,
artistName: song.artists?.[0]?.name,
currentStatus: current[2],
currentTime: current[1],
displayType: discordSettings.displayType,
hasLargeImage: !!activity.largeImageKey,
hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),
reason,
showAsListening: discordSettings.showAsListening,
songName: song.name,
trackChanged,
trigger,
},
});
discordRpc?.setActivity(activity);
},
[
discordSettings.showAsListening,
@@ -390,7 +359,7 @@ export const useDiscordRpc = () => {
],
);
const debouncedSetActivity = useDebouncedCallback(setActivity, 500);
const debouncedSetActivity = useDebouncedCallback(setActivity, 1000);
// Quit Discord RPC if it was enabled and is now disabled
useEffect(() => {
@@ -409,95 +378,110 @@ export const useDiscordRpc = () => {
}
}, [discordSettings.clientId, privateMode, discordSettings.enabled]);
const getCurrentActivityState = useCallback((): ActivityState => {
const state = usePlayerStore.getState();
return [
state.getCurrentSong(),
useTimestampStoreBase.getState().timestamp,
state.player.status,
];
}, []);
const clearRefreshInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const emitActivityUpdateRef = useRef<(next: ActivityState, trigger: ActivityTrigger) => void>(
() => {},
);
const resetRefreshInterval = useCallback(() => {
clearRefreshInterval();
intervalRef.current = setInterval(() => {
const current = getCurrentActivityState();
emitActivityUpdateRef.current(current, 'interval');
}, 15000);
}, [clearRefreshInterval, getCurrentActivityState]);
const emitActivityUpdate = useCallback(
(next: ActivityState, trigger: ActivityTrigger) => {
debouncedSetActivity(next, trigger);
resetRefreshInterval();
},
[debouncedSetActivity, resetRefreshInterval],
);
useEffect(() => {
emitActivityUpdateRef.current = emitActivityUpdate;
}, [emitActivityUpdate]);
useEffect(() => {
if (!discordSettings.enabled || privateMode) {
clearRefreshInterval();
return;
}
const getCurrentActivityState = (): ActivityState => {
const state = usePlayerStore.getState();
const currentSong = state.getCurrentSong();
const currentTime = useTimestampStoreBase.getState().timestamp;
const status = state.player.status;
return [currentSong, currentTime, status];
};
const resetInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
const current = getCurrentActivityState();
const previous = previousActivityStateRef.current || current;
debouncedSetActivity(current, previous);
previousActivityStateRef.current = current;
}, 15000);
};
resetInterval();
const initialState = getCurrentActivityState();
let previousUniqueId = initialState[0]?._uniqueId || '';
previousActivityStateRef.current = initialState;
// Set activity immediately when Discord RPC is enabled
debouncedSetActivity(initialState, initialState);
const unsubSongChange = usePlayerStore.subscribe(
(state): ActivityState => {
const currentSong = state.getCurrentSong();
const currentTime = useTimestampStoreBase.getState().timestamp;
const status = state.player.status;
return [currentSong, currentTime, status];
},
(current, previous) => {
const currentUniqueId = current[0]?._uniqueId || '';
const trackChanged = previousUniqueId !== currentUniqueId;
if (trackChanged && current[0]) {
resetInterval();
previousUniqueId = currentUniqueId;
}
const activity: ActivityState = [
current[0] as QueueSong,
current[1] as number,
current[2] as PlayerStatus,
];
// Use the ref as the source of truth for previous state
const previousActivity: ActivityState =
previousActivityStateRef.current ||
(previous
? [
previous[0] as QueueSong,
previous[1] as number,
previous[2] as PlayerStatus,
]
: activity);
debouncedSetActivity(activity, previousActivity);
previousActivityStateRef.current = activity;
},
);
emitActivityUpdate(initialState, 'initial');
return () => {
unsubSongChange();
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
clearRefreshInterval();
};
}, [
debouncedSetActivity,
discordSettings.clientId,
clearRefreshInterval,
discordSettings.enabled,
emitActivityUpdate,
getCurrentActivityState,
privateMode,
setActivity,
]);
usePlayerEvents(
{
onCurrentSongChange: ({ song }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
song,
useTimestampStoreBase.getState().timestamp,
playerState.player.status,
];
emitActivityUpdateRef.current(activityState, 'track_change');
},
onPlayerSeekToTimestamp: ({ timestamp }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
playerState.getCurrentSong(),
timestamp,
playerState.player.status,
];
emitActivityUpdateRef.current(activityState, 'seek');
},
onPlayerStatus: ({ status }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
playerState.getCurrentSong(),
useTimestampStoreBase.getState().timestamp,
status,
];
emitActivityUpdateRef.current(activityState, 'status_change');
},
},
[],
);
};
const DiscordRpcHookInner = () => {
+43 -17
View File
@@ -109,12 +109,8 @@ export function computeSelectedFromResult(
};
}
const hasLocalLocal =
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
// If setting is set to prefer local lyrics, return the local lyrics if available
if (preferLocalLyrics && hasLocalLocal) {
if (preferLocalLyrics && hasLocalLyrics(local)) {
if (Array.isArray(local) && local.length > 0) {
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
return { selected: item, selectedSynced: item.synced };
@@ -236,6 +232,13 @@ export function getDisplayOffset(
return storedOffsetMs;
}
export function hasLocalLyrics(local: FullLyricsMetadata | null | StructuredLyric[]): boolean {
return (
(Array.isArray(local) && local.length > 0) ||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics))
);
}
const emptyResult = (): LyricsQueryResult => ({
local: null,
overrideData: null,
@@ -277,16 +280,11 @@ export const lyricsQueries = {
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
// Fetch local lyrics
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
// Fetch remote auto lyrics
const remoteAutoPromise =
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
? null
: fetchRemoteLyricsAuto(song);
// Fetch override data
const overrideDataPromise = overrideSelection
? fetchRemoteLyricsById({
remoteSongId: overrideSelection.id,
@@ -295,11 +293,40 @@ export const lyricsQueries = {
})
: null;
const [local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
let local: FullLyricsMetadata | null | StructuredLyric[];
let remoteAuto: FullLyricsMetadata | null;
let overrideData: LyricsResponse | null;
if (preferLocalLyrics) {
local = await localPromise;
if (hasLocalLyrics(local)) {
overrideData = overrideDataPromise ? await overrideDataPromise : null;
remoteAuto = null;
if (remoteAutoPromise) {
void remoteAutoPromise.then((fetchedRemoteAuto) => {
if (signal.aborted || !fetchedRemoteAuto) return;
queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>
prev ? { ...prev, remoteAuto: fetchedRemoteAuto } : prev,
);
});
}
} else {
[remoteAuto, overrideData] = await Promise.all([
remoteAutoPromise,
overrideDataPromise,
]);
}
} else {
[local, remoteAuto, overrideData] = await Promise.all([
localPromise,
remoteAutoPromise,
overrideDataPromise,
]);
}
const partial: Pick<
LyricsQueryResult,
@@ -320,13 +347,12 @@ export const lyricsQueries = {
preferLocalLyrics,
selectedStructuredIndex,
);
const displayOffset = getDisplayOffset(
const resultSelectedOffsetMs = getDisplayOffset(
selected,
selectedOffsetMs,
selectedStructuredIndex,
local,
);
const resultSelectedOffsetMs = displayOffset;
return {
...emptyResult(),
@@ -1,6 +1,6 @@
import { useIsFetching } from '@tanstack/react-query';
import { t } from 'i18next';
import { RefObject } from 'react';
import { RefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './play-queue-list-controls.module.css';
@@ -21,6 +21,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { toast } from '/@/shared/components/toast/toast';
import { ServerFeature } from '/@/shared/types/features-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
const { isPending: isSavingQueue, mutate: saveQueue } = useSaveQueue();
const handleSaveQueue = useCallback(() => {
saveQueue(undefined, {
onSuccess: () => {
toast.success({
message: t('form.saveQueue.success'),
});
},
});
}, [saveQueue]);
const handleRestoreQueue = useRestoreQueue();
@@ -214,7 +214,14 @@ export const SidebarPlayQueue = () => {
))}
</SplitPane>
) : (
<Stack gap={0} h="100%" w="100%">
<Stack
gap={0}
style={{
flex: 1,
minHeight: 0,
}}
w="100%"
>
<PlayQueueListControls
handleSearch={setSearch}
searchTerm={search}
@@ -8,7 +8,7 @@ import { QueueSong } from '/@/shared/types/domain-types';
export function useSongUrl(
song: QueueSong | undefined,
current: boolean,
transcode: TranscodingConfig,
transcode: Partial<TranscodingConfig>,
): string | undefined {
const prior = useRef(['', '']);
const shouldReusePrior = Boolean(
@@ -24,7 +24,7 @@ export function useSongUrl(
bitrate: transcode.bitrate,
format: transcode.format,
id: song!.id,
transcode: transcode.enabled,
transcode: transcode.enabled ?? false,
},
}),
queryKey: [
@@ -63,7 +63,7 @@ export function useSongUrl(
export const getSongUrl = async (
song: QueueSong,
transcode: TranscodingConfig,
transcode: Partial<TranscodingConfig>,
skipAutoTranscode?: boolean,
) => {
const url = await api.controller.getStreamUrl({
@@ -73,7 +73,7 @@ export const getSongUrl = async (
format: transcode.format,
id: song.id,
skipAutoTranscode,
transcode: transcode.enabled,
transcode: transcode.enabled ?? false,
},
});
@@ -153,6 +153,7 @@ export function WebPlayer() {
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
hasNextSong: Boolean(player2),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -206,6 +207,7 @@ export function WebPlayer() {
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
hasNextSong: Boolean(player1),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -680,6 +682,7 @@ function exponentialEaseOut(t: number): number {
function gaplessHandler(args: {
currentTime: number;
duration: number;
hasNextSong: boolean;
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
@@ -688,7 +691,19 @@ function gaplessHandler(args: {
};
setIsTransitioning: Dispatch<boolean | string>;
}) {
const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;
const {
currentTime,
duration,
hasNextSong,
isFlac,
isTransitioning,
nextPlayer,
setIsTransitioning,
} = args;
if (!hasNextSong) {
return null;
}
if (!isTransitioning) {
if (currentTime > duration - 2) {
@@ -9,7 +9,13 @@ import styles from './playerbar-waveform.module.css';
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store';
import {
BarAlign,
usePlaybackSettings,
usePlayerbarSlider,
usePlayerSong,
usePlayerTimestamp,
} from '/@/renderer/store';
import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme';
import { Text } from '/@/shared/components/text/text';
@@ -30,7 +36,12 @@ export const PlayerbarWaveform = () => {
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: false, format: 'mp3' });
const { transcode } = usePlaybackSettings();
const streamUrl = useSongUrl(currentSong, true, {
bitrate: 64,
enabled: transcode.enabled,
format: 'mp3',
});
const { color } = useAppThemeColors();
const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)';
@@ -61,7 +61,7 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
);
return (
<HoverCard position="top" width={280}>
<HoverCard openDelay={500} position="top" width={280}>
<HoverCard.Target>
<Group
align="center"
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
import {
usePlayerShuffle,
usePlayerStatus,
usePlayerStoreBase,
} from '/@/renderer/store/player.store';
import {
useSleepTimerActions,
useSleepTimerActive,
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const },
{ minutes: 0, mode: 'endOfAlbum' as const },
{ minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const },
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
const useSleepTimer = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions();
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
// End of album mode. Set the pauseOnNextSongEnd flag whenever the current track
// is the last one of the target album.
const evaluateEndOfAlbum = useCallback(() => {
const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData();
if (!currentSong) {
return;
}
let target = useSleepTimerStore.getState().targetAlbumId;
if (target === null) {
target = currentSong.albumId;
setTargetAlbumId(target);
}
if (currentSong.albumId !== target) {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
cancelTimer();
return;
}
const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId;
usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum);
}, [cancelTimer, setTargetAlbumId]);
const handleOnCurrentSongChange = useCallback(() => {
if (!active) {
return;
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
if (mode === 'endOfSong') {
cancelTimer();
mediaPauseRef.current();
return;
}
}, [active, mode, cancelTimer, mediaPauseRef]);
// Cancel and pause song change in end-of-album mode
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
}
}, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]);
const status = usePlayerStatus();
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
// mediaAutoNext returns PAUSED status when the current song ends.
// This is a generic player mechanism — the web player handles it
// without needing to know about the sleep timer.
// End-of-album mode: set the same flag conditionally, here we run
// the intial evaluation in case the timer was started while already
// on the last track of the album
useEffect(() => {
if (!active || mode !== 'endOfSong') return;
if (!active) {
return;
}
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
if (mode === 'endOfSong') {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}, [active, mode]);
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
if (mode === 'endOfAlbum') {
evaluateEndOfAlbum();
return () => {
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
};
}
return undefined;
}, [active, mode, evaluateEndOfAlbum]);
};
export const SleepTimerHookInner = () => {
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
useSleepTimerActions();
const { mediaPause } = usePlayer();
const shuffle = usePlayerShuffle();
// Track level shuffle scatters and album across a play queue making 'end-of-album'
// meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album
// enabled there
const isTrackShuffle = shuffle === PlayerShuffle.TRACK;
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0);
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
(option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
startEndOfSongTimer();
} else if (option.mode === 'endOfAlbum') {
startEndOfAlbumTimer();
} else {
startTimedTimer(option.minutes * 60);
}
setShowCustom(false);
setOpened(false);
},
[startEndOfSongTimer, startTimedTimer],
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
);
const handleCustomStart = useCallback(() => {
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong');
}
if (option.mode === 'endOfAlbum') {
return t('player.sleepTimer_endOfAlbum');
}
if (option.minutes >= 60) {
return t('player.sleepTimer_hours', {
count: option.minutes / 60,
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong')}
</Text>
) : mode === 'endOfAlbum' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfAlbum')}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
</Flex>
)}
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
(option, index) => (
{PRESET_OPTIONS.filter(
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
).map((option) => {
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
return (
<Button
disabled={disabled}
fullWidth
justify="flex-start"
key={index}
key={option.mode}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
>
{getPresetLabel(option)}
</Button>
),
)}
);
})}
<Divider my="md" />
@@ -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,26 +170,20 @@ export const useSaveQueue = () => {
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
}
try {
await api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
toast.success({
message: t('form.saveQueue.success'),
});
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
throw error;
}
return api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs: queue.items.map((item) => item.id),
},
});
},
onError: (error) => {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed'),
});
},
});
@@ -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);
@@ -180,9 +182,6 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp;
@@ -239,36 +238,36 @@ export const useScrobble = () => {
}
// Send progress events every 10 seconds
if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) {
sendScrobble.mutate(
{
apiClientProps: { serverId: serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'timeupdate',
id: currentSong.id,
mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
lastProgressEventRef.current = currentTime;
}
}
// if (hasPlaybackReport) {
// const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
// if (timeSinceLastProgress >= 10) {
// sendScrobble.mutate(
// {
// apiClientProps: { serverId: serverId || '' },
// query: {
// albumId: currentSong.albumId,
// event: 'timeupdate',
// id: currentSong.id,
// mediaType: mediaType,
// playbackRate,
// position: getPositionValue(currentTime, useTicks),
// submission: false,
// },
// },
// {
// onSuccess: () => {
// logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
// category: LogCategory.SCROBBLE,
// meta: {
// id: currentSong.id,
// },
// });
// },
// },
// );
// lastProgressEventRef.current = currentTime;
// }
// }
// Check if we should submit scrobble based on listened time
if (!isCurrentSongScrobbledRef.current) {
@@ -319,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) {
@@ -355,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();
@@ -398,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();
},
[
@@ -462,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,
@@ -592,6 +629,7 @@ export const useScrobble = () => {
isCurrentSongScrobbledRef.current = false;
lastProgressEventRef.current = 0;
previousTimestampRef.current = 0;
stopPositionRef.current = 0;
listenedMsRef.current = 0;
lastListenSampleTimeRef.current = null;
@@ -626,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();
@@ -74,6 +74,7 @@ export const PlaylistListInfiniteTable = ({
columns={columns}
data={loadedItems}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
@@ -87,6 +87,7 @@ export const PlaylistListPaginatedTable = ({
columns={columns}
data={data || []}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
@@ -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'),
},
{
@@ -18,6 +18,10 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Icon } from '/@/shared/components/icon/icon';
import { Table } from '/@/shared/components/table/table';
import { TextInput } from '/@/shared/components/text-input/text-input';
import {
keyboardCodeToHotkeyKey,
MODIFIER_KEY_CODES,
} from '/@/shared/utils/keyboard-code-to-hotkey';
const ipc = isElectron() ? window.api.ipc : null;
@@ -112,25 +116,16 @@ export const HotkeyManagerSettings = memo(() => {
const debouncedSetHotkey = debounce(
(binding: BindingActions, e: KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape'];
const keys: string[] = [];
if (e.ctrlKey) keys.push('mod');
if (e.altKey) keys.push('alt');
if (e.shiftKey) keys.push('shift');
if (e.metaKey) keys.push('meta');
if (e.key === ' ') keys.push('space');
if (!IGNORED_KEYS.includes(e.key)) {
if (e.code.includes('Numpad')) {
if (e.key === '+') keys.push('numpadadd');
else if (e.key === '-') keys.push('numpadsubtract');
else if (e.key === '*') keys.push('numpadmultiply');
else if (e.key === '/') keys.push('numpaddivide');
else if (e.key === '.') keys.push('numpaddecimal');
else keys.push(`numpad${e.key}`.toLowerCase());
} else if (e.key === '+') {
keys.push('equal');
} else {
keys.push(e.key?.toLowerCase());
if (!MODIFIER_KEY_CODES.has(e.code) && e.code !== 'Escape') {
const hotkeyKey = keyboardCodeToHotkeyKey(e.code);
if (hotkeyKey) {
keys.push(hotkeyKey);
}
}
@@ -36,13 +36,12 @@ export const WindowSettings = memo(() => {
if (!e) return;
// Platform.LINUX is used as the native frame option regardless of the actual platform
const hasFrame = localSettings?.get('window_has_frame') as
| boolean
| undefined;
const isSwitchingToFrame = !hasFrame && e === Platform.LINUX;
const isSwitchingToNoFrame = hasFrame && e !== Platform.LINUX;
const requireRestart = isSwitchingToFrame || isSwitchingToNoFrame;
const previousWindowBarStyle = settings.windowBarStyle;
const isSwitchingToNative =
previousWindowBarStyle !== Platform.LINUX && e === Platform.LINUX;
const isSwitchingFromNative =
previousWindowBarStyle === Platform.LINUX && e !== Platform.LINUX;
const requireRestart = isSwitchingToNative || isSwitchingFromNative;
if (requireRestart) {
openRestartRequiredToast();
+5 -1
View File
@@ -2,8 +2,10 @@ import {
type HotkeyItem as MantineHotkeyItem,
useHotkeys as useMantineHotkeys,
} from '@mantine/hooks';
import { useMemo } from 'react';
import { useAppStore } from '/@/renderer/store';
import { withPhysicalKeys } from '/@/shared/utils/hotkeys';
const EMPTY_HOTKEYS: MantineHotkeyItem[] = [];
@@ -13,8 +15,10 @@ export const useHotkeys = (
triggerOnContentEditable?: boolean,
) => {
const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened);
const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]);
useMantineHotkeys(
commandPaletteOpened ? EMPTY_HOTKEYS : hotkeys,
commandPaletteOpened ? EMPTY_HOTKEYS : physicalHotkeys,
tagsToIgnore,
triggerOnContentEditable,
);
@@ -298,6 +298,7 @@ export const useServerAuthenticated = () => {
await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));
// Retry authentication
// eslint-disable-next-line react-hooks/immutability
return authenticateServer(serverWithAuth, nextRetry);
}
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
const settingsFromStore = useSettingsStore.getState();
const settings = {
font: settingsFromStore.font,
general: settingsFromStore.general,
hotkeys: settingsFromStore.hotkeys,
lyrics: settingsFromStore.lyrics,
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
mainStoreKey: 'enableNeteaseTranslation',
rendererValue: settings.lyrics.enableNeteaseTranslation,
},
{
mainStoreKey: 'local_font_path',
rendererValue: settings.font.custom,
},
];
// Compare and sync each setting
+1 -1
View File
@@ -189,7 +189,7 @@ const appRouterModals = {
export const AppRouter = () => {
const router = (
<HashRouter unstable_useTransitions={false}>
<HashRouter>
<ModalsProvider modals={appRouterModals}>
<RouterErrorBoundary>
<Routes>
+17 -4
View File
@@ -223,7 +223,7 @@ function calculateNextIndex(
} else {
// Repeat none: move to next track, or pause if at the end
if (isLastTrack) {
return { nextIndex: 0, shouldPause: true };
return { nextIndex: currentIndex, shouldPause: true };
} else {
return { nextIndex: currentIndex + 1, shouldPause: false };
}
@@ -939,10 +939,12 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const pauseOnNext = player.pauseOnNextSongEnd;
const newStatus =
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
const shouldKeepCurrentPlayer = newStatus === PlayerStatus.PAUSED;
const shouldSwapPlayer = !isRepeatOneSameTrack && !shouldKeepCurrentPlayer;
set((state) => {
state.player.index = nextPlaybackIndex;
state.player.playerNum = newPlayerNum;
state.player.playerNum = shouldSwapPlayer ? newPlayerNum : player.playerNum;
setTimestampStore(0);
state.player.status = newStatus;
@@ -999,7 +1001,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
}
const { player1, player2 } = getDualPlayerSongs(
newPlayerNum,
shouldSwapPlayer ? newPlayerNum : player.playerNum,
currentSong,
nextSong,
repeat,
@@ -1009,7 +1011,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
currentSong,
index: currentQueueIndex,
nextSong,
num: newPlayerNum,
num: shouldSwapPlayer ? newPlayerNum : player.playerNum,
player1,
player2,
previousSong,
@@ -1183,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);
});
@@ -1194,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);
});
@@ -1215,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
// See mediaSkipBackward: update the timestamp store right away to
// avoid the stale-read left by the ~500ms engine poll.
setTimestampStore(newTimestamp);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
+25 -1
View File
@@ -1,11 +1,13 @@
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed';
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
interface SleepTimerActions {
cancelTimer: () => void;
setRemaining: (remaining: number) => void;
setTargetAlbumId: (albumId: null | string) => void;
startEndOfAlbumTimer: () => void;
startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void;
}
@@ -17,6 +19,8 @@ interface SleepTimerState {
mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */
remaining: number;
/** Album Id for song when mode activated */
targetAlbumId: null | string;
}
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: false,
mode: 'timed',
remaining: 0,
targetAlbumId: null,
});
},
mode: 'timed',
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
set({ remaining });
},
setTargetAlbumId: (albumId: null | string) => {
set({ targetAlbumId: albumId });
},
startEndOfAlbumTimer: () => {
set({
active: true,
mode: 'endOfAlbum',
remaining: 0,
targetAlbumId: null,
});
},
startEndOfSongTimer: () => {
set({
active: true,
mode: 'endOfSong',
remaining: 0,
targetAlbumId: null,
});
},
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
active: true,
mode: 'timed',
remaining: durationSeconds,
targetAlbumId: null,
});
},
targetAlbumId: null,
}),
);
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
setTargetAlbumId: s.setTargetAlbumId,
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),
+6
View File
@@ -47,6 +47,7 @@ export const THEME_DATA = [
{ label: 'Rosé Pine', type: 'dark', value: AppTheme.ROSE_PINE },
{ label: 'Rosé Pine Moon', type: 'dark', value: AppTheme.ROSE_PINE_MOON },
{ label: 'Rosé Pine Dawn', type: 'light', value: AppTheme.ROSE_PINE_DAWN },
{ label: 'Zenburn', type: 'dark', value: AppTheme.ZENBURN },
];
export const useAppTheme = (overrideTheme?: AppTheme) => {
@@ -134,6 +135,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
document.body.appendChild(textStyleRef.current);
}
// Note: we change the url to bust caches when changing the path
// The url provided here does NOT matter, validation is done
// on the main process. Any feishin:/ url will fetch the same
// item, which the renderer will check via magic number to be
// some font item
textStyleRef.current.textContent = `
@font-face {
font-family: "dynamic-font";
+1
View File
@@ -107,6 +107,7 @@ export const logMsg = {
[LogCategory.SCROBBLE]: {
scrobbledPause: 'Scrobbled a pause event',
scrobbledStart: 'Scrobbled a start event',
scrobbledStop: 'Scrobbled a stop event',
scrobbledSubmission: 'Scrobbled a submission event',
scrobbledTimeupdate: 'Scrobbled a timeupdate event',
scrobbledUnpause: 'Scrobbled an unpause event',
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
playCount: item.UserData?.PlayCount || 0,
similarArtists,
songCount: item.SongCount ?? null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
@@ -434,6 +435,7 @@ const normalizePlaylist = (
size: null,
songCount: item?.ChildCount || null,
sync: null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
};
};
+12
View File
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
const deletePlaylist = z.null();
const deletePlaylistImage = z.null();
const deleteArtistImage = deletePlaylistImage;
const uploadPlaylistImage = z.null();
const uploadArtistImage = uploadPlaylistImage;
const deletePlaylistParameters = z.object({
Id: z.string(),
});
@@ -886,7 +894,9 @@ export const jfType = {
albumList,
authenticate,
createPlaylist,
deleteArtistImage,
deletePlaylist,
deletePlaylistImage,
error,
favorite,
filters,
@@ -912,6 +922,8 @@ export const jfType = {
studioList,
topSongsList,
updatePlaylist,
uploadArtistImage,
uploadPlaylistImage,
user,
},
};
+11 -1
View File
@@ -2,7 +2,17 @@ import {
type HotkeyItem as MantineHotkeyItem,
useHotkeys as useMantineHotkeys,
} from '@mantine/hooks';
import { useMemo } from 'react';
export const useHotkeys = useMantineHotkeys;
import { withPhysicalKeys } from '/@/shared/utils/hotkeys';
export const useHotkeys = (
hotkeys: MantineHotkeyItem[],
tagsToIgnore?: string[],
triggerOnContentEditable?: boolean,
) => {
const physicalHotkeys = useMemo(() => withPhysicalKeys(hotkeys), [hotkeys]);
useMantineHotkeys(physicalHotkeys, tagsToIgnore, triggerOnContentEditable);
};
export type HotkeyItem = MantineHotkeyItem;
+1
View File
@@ -34,6 +34,7 @@ export enum AppTheme {
TOKYO_NIGHT = 'tokyoNight',
VSCODE_DARK_PLUS = 'vscodeDarkPlus',
VSCODE_LIGHT_PLUS = 'vscodeLightPlus',
ZENBURN = 'zenburn',
}
export type AppThemeConfiguration = Partial<BaseAppThemeConfiguration>;
+2
View File
@@ -35,6 +35,7 @@ import { solarizedLight } from '/@/shared/themes/solarized-light/solarized-light
import { tokyoNight } from '/@/shared/themes/tokyo-night/tokyo-night';
import { vscodeDarkPlus } from '/@/shared/themes/vscode-dark-plus/vscode-dark-plus';
import { vscodeLightPlus } from '/@/shared/themes/vscode-light-plus/vscode-light-plus';
import { zenburn } from '/@/shared/themes/zenburn/zenburn';
export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
[AppTheme.AYU_DARK]: ayuDark,
@@ -68,6 +69,7 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
[AppTheme.TOKYO_NIGHT]: tokyoNight,
[AppTheme.VSCODE_DARK_PLUS]: vscodeDarkPlus,
[AppTheme.VSCODE_LIGHT_PLUS]: vscodeLightPlus,
[AppTheme.ZENBURN]: zenburn,
};
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
+28
View File
@@ -0,0 +1,28 @@
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
export const zenburn: AppThemeConfiguration = {
app: {
'overlay-header':
'linear-gradient(transparent 0%, rgb(40 44 52 / 85%) 100%), var(--theme-background-noise)',
'overlay-subheader':
'linear-gradient(180deg, rgb(40 44 52 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
'scrollbar-handle-background': 'rgba(160, 160, 160, 20%)',
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 40%)',
},
colors: {
background: '#3f3f3f',
'background-alternate': '#313131',
black: '#313131',
foreground: '#dcdccc',
'foreground-muted': '#d9d9d9',
primary: '#95a4b2',
'state-error': '#dca3a3',
'state-info': '#95a4b2',
'state-success': '#7f9f7f',
'state-warning': '#efdcbc',
surface: '#636363',
'surface-foreground': '#95a4b2',
white: '#dcdccc',
},
mode: 'dark',
};
+7 -5
View File
@@ -465,6 +465,8 @@ export const tagListSortMap: TagListSortMap = {
},
};
export const SortKeyRandom = 'random';
export enum AlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
@@ -476,7 +478,7 @@ export enum AlbumListSort {
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
@@ -598,7 +600,7 @@ export enum SongListSort {
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
@@ -725,7 +727,7 @@ export enum AlbumArtistListSort {
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
@@ -814,7 +816,7 @@ export enum ArtistListSort {
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RANDOM = SortKeyRandom,
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
@@ -1363,7 +1365,7 @@ export type ScrobbleArgs = BaseEndpointArgs & {
export type ScrobbleQuery = {
albumId?: string;
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
event?: 'pause' | 'start' | 'stop' | 'unpause';
id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;
+56
View File
@@ -0,0 +1,56 @@
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
* "digit1" but leaves "1" as "1" so mod+1 must become mod+Digit1.
*/
export const toPhysicalHotkey = (hotkey: string): string =>
hotkey
.split('+')
.map((part) => part.trim())
.map((part) => {
if (part === '[plus]') {
return part;
}
const lower = part.toLowerCase();
if (RESERVED_KEYS.has(lower)) {
return lower;
}
if (/^\d$/.test(part)) {
return `Digit${part}`;
}
const punctuationPhysical = PUNCTUATION_KEY_TO_PHYSICAL[part];
if (punctuationPhysical) {
return punctuationPhysical;
}
return part;
})
.join('+');
export const withPhysicalKeys = (hotkeys: HotkeyItem[]): HotkeyItem[] =>
hotkeys.map(([hotkey, handler, options]) => [
toPhysicalHotkey(hotkey),
handler,
{ ...options, preventDefault: true, usePhysicalKeys: true },
]);
@@ -0,0 +1,77 @@
const CODE_TO_HOTKEY_KEY: Record<string, string> = {
ArrowDown: 'arrowdown',
ArrowLeft: 'arrowleft',
ArrowRight: 'arrowright',
ArrowUp: 'arrowup',
Backquote: '`',
Backslash: '\\',
Backspace: 'backspace',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Delete: 'delete',
End: 'end',
Enter: 'enter',
Equal: 'equal',
Escape: 'escape',
Home: 'home',
Insert: 'insert',
Minus: 'minus',
PageDown: 'pagedown',
PageUp: 'pageup',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Space: 'space',
Tab: 'tab',
};
const NUMPAD_CODE_TO_HOTKEY_KEY: Record<string, string> = {
Add: 'numpadadd',
Decimal: 'numpaddecimal',
Divide: 'numpaddivide',
Enter: 'numpadenter',
Multiply: 'numpadmultiply',
Subtract: 'numpadsubtract',
};
export const MODIFIER_KEY_CODES = new Set([
'AltLeft',
'AltRight',
'ControlLeft',
'ControlRight',
'MetaLeft',
'MetaRight',
'ShiftLeft',
'ShiftRight',
]);
export const keyboardCodeToHotkeyKey = (code: string): null | string => {
const mapped = CODE_TO_HOTKEY_KEY[code];
if (mapped) {
return mapped;
}
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase();
}
if (code.startsWith('Digit')) {
return code.slice(5);
}
if (code.startsWith('Numpad')) {
const suffix = code.slice(6);
const numpadMapped = NUMPAD_CODE_TO_HOTKEY_KEY[suffix];
if (numpadMapped) {
return numpadMapped;
}
if (/^\d$/.test(suffix)) {
return `numpad${suffix}`;
}
}
return null;
};