Compare commits

...

71 Commits

Author SHA1 Message Date
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
Hosted Weblate 2193fa4251 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate

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

Translated using Weblate

Currently translated at 15.8% (195 of 1231 strings) (Arabic)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ar/

Translated using Weblate

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

Translated using Weblate

Currently translated at 54.3% (669 of 1230 strings) (German)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Laalo <hyohnoo3@gmail.com>
Co-authored-by: Timon Seidel <timongcraft@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2026-05-26 06:05:29 +02:00
jeffvli 9124604b89 update to v1.12.0 2026-05-25 21:01:37 -07:00
jeffvli 239ef4a4ec add album mode for autodj
- add selection modes: similar, random
- add autodj settings in playerbar popover
2026-05-25 19:23:37 -07:00
jeffvli f3b72504f1 fix missing count configuration for track radio 2026-05-25 19:10:45 -07:00
Simon Bråten f098f848a3 feat: reading custom css from external file (#2012)
* feat: reading custom css from external file

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-05-25 14:53:53 -07:00
muckymucky 22d37135ae fix: strip playback accelerators whenever any text input is focused (#2059)
The command-palette-specific IPC approach didn't cover other modals
with inputs (settings search, playlist creation, etc.). Replace it
with document-level focusin/focusout listeners that signal the main
process whenever any input/textarea/contenteditable gains or loses
focus, so the menu rebuild is triggered automatically for all current
and future input surfaces.

Co-authored-by: muckymucky <muckymucky@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:35:45 -07:00
jeffvli 61c6036d41 cap scrobble status at max values 2026-05-25 13:26:12 -07:00
jeffvli 95ae474cc6 fix playerbar slider styles
- left / right values should be consistent width
2026-05-25 13:26:12 -07:00
jeffvli 504bbeed91 handle positional scrobbles for OS 2026-05-25 13:26:11 -07:00
jeffvli 0e5b3450dd set ignoreScrobble, playbackRate as optional params 2026-05-25 13:26:11 -07:00
jeffvli 650ae0b320 add reportPlayback as default Jellyfin feature 2026-05-25 13:26:11 -07:00
jeffvli 66cfab3b57 add playbackReport handler to Subsonic controller 2026-05-25 13:26:10 -07:00
jeffvli 0455d5bfb8 add reportPlayback server feature 2026-05-25 13:26:09 -07:00
jeffvli 112449576e add OS reportPlayback endpoint and types 2026-05-25 13:26:09 -07:00
muckymucky 3122f4121e fix: strip playback menu accelerators while command palette is open (#2055)
On macOS, menu item accelerators (e.g. Space for Play/Pause) fire even
when an input has focus, bypassing the renderer-side useHotkeys guard
added in #1925. Typing a space character in the command palette search
toggled play/pause instead of inserting a space.

Track command palette open state in the main process via IPC. When the
palette is open, rebuild the application menu with empty playback
accelerators so single-key shortcuts no longer intercept keystrokes
intended for the search input. Restore accelerators when the palette
closes.

Co-authored-by: Jack McCrea <jack@MacBookPro.lan>
2026-05-25 11:24:15 -07:00
Hosted Weblate 28b8894b49 Translated using Weblate
Currently translated at 53.2% (655 of 1230 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Co-authored-by: Timon Seidel <svc-weblate-24123097555425b5@timongcraft.de>
2026-05-24 16:11:35 +02:00
Hosted Weblate 41d5694f1f Translated using Weblate
Currently translated at 100.0% (1230 of 1230 strings) (Indonesian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/id/

Translated using Weblate

Currently translated at 57.4% (707 of 1230 strings) (Portuguese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt/

Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
2026-05-23 13:11:45 +00:00
Kendall Garner 7befd70e21 Apply additional security recommendations (#2050)
* enable sandbox

* enable CSP (umami tentatively works?) and reduce amount of ipc APIs exposed

* remove csp from index
2026-05-22 22:09:22 -07:00
Hosted Weblate 0de1e1aa3e Translated using Weblate
Currently translated at 26.0% (320 of 1230 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Napster2k <Napster2k@gmail.com>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-05-22 11:11:40 +00:00
jeffvli 6528859ea9 adjust ItemDetail row play control alignment to match ItemTable 2026-05-22 03:43:48 -07:00
jeffvli 704745eb7c fix item table crashing with row play controls
- hovercard causes issues with the image component as the component is mounted/unmounted within the portal
2026-05-22 03:28:37 -07:00
jeffvli 2759cfee97 Revert "fix NativeImage state causing crashes in table"
This reverts commit 4008c8dfdf.
2026-05-22 01:56:50 -07:00
jeffvli 755f0aab9d fix mediasession breaking on player repeat (#1472)
- switch to single web player instance for loop instead of dual-player
- this fixes the issue, but does have a breaking change if using the crossfade player
2026-05-22 01:32:22 -07:00
jeffvli 0e163543fc update lockfile for electron v41 2026-05-21 11:30:09 -07:00
jeffvli fad2534d44 update to electron v41 in deps 2026-05-21 11:28:13 -07:00
jeffvli edfc64d790 update to electron v41 (#2047) 2026-05-21 11:20:55 -07:00
Tiago Simionato 8f40894926 feat: persist player timestamp (#2043)
* feat: persist player timestamp
2026-05-21 00:16:46 -07:00
Kendall Garner 2befcb4e74 disable node integration (#2049) 2026-05-20 21:06:28 -07:00
jeffvli 2d78c32a68 prevent track radio from prepending song in queue
- this behavior relates to when using NEXT or LAST as this will add a duplicate song
2026-05-20 20:49:29 -07:00
jeffvli 3d0500980a implement row playback controls for the detail list 2026-05-20 20:40:30 -07:00
jeffvli 3551ee5077 prevent desktopCaturer from running unless visualizer explicitly requested (#1931) 2026-05-20 20:34:25 -07:00
130 changed files with 4680 additions and 1303 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
+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
+5 -4
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,15 +30,15 @@ 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.
+2 -1
View File
@@ -5,7 +5,8 @@ WORKDIR /app
# Copy package.json first to cache node_modules
COPY package.json pnpm-lock.yaml .
RUN npm install -g pnpm
# Match CI (pnpm/action-setup version: 10). Latest pnpm 11 fails install without approve-builds.
RUN corepack enable && corepack prepare pnpm@10 --activate
RUN pnpm install
+3
View File
@@ -114,8 +114,11 @@ These variables override app settings **on first run** when no persisted setting
| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |
| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |
| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. |
| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |
---
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
+2 -2
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
@@ -47,7 +47,7 @@ mac:
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
+1 -1
View File
@@ -1,7 +1,7 @@
appId: org.jeffvli.feishin
productName: Feishin
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
electronVersion: 39.4.0
electronVersion: 41.7.0
directories:
buildResources: assets
files:
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.11.0",
"version": "1.13.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -160,7 +160,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.8.6",
"electron": "^41.7.0",
"electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
@@ -189,6 +189,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild"
]
},
+13 -25
View File
@@ -23,10 +23,10 @@ importers:
version: 1.1.0
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@39.8.6)
version: 3.0.2(electron@41.7.0)
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@39.8.6)
version: 4.0.0(electron@41.7.0)
'@mantine/colors-generator':
specifier: ^9.1.1
version: 9.1.1(chroma-js@3.1.2)
@@ -269,8 +269,8 @@ importers:
specifier: ^10.1.0
version: 10.1.0
electron:
specifier: ^39.8.6
version: 39.8.6
specifier: ^41.7.0
version: 41.7.0
electron-builder:
specifier: ^26.8.2
version: 26.8.2(electron-builder-squirrel-windows@26.8.2)
@@ -2126,9 +2126,6 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
@@ -3001,8 +2998,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@39.8.6:
resolution: {integrity: sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==}
electron@41.7.0:
resolution: {integrity: sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==}
engines: {node: '>= 12.20.55'}
hasBin: true
@@ -5507,9 +5504,6 @@ packages:
underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -6684,17 +6678,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron-toolkit/preload@3.0.2(electron@39.8.6)':
'@electron-toolkit/preload@3.0.2(electron@41.7.0)':
dependencies:
electron: 39.8.6
electron: 41.7.0
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies:
'@types/node': 24.12.2
'@electron-toolkit/utils@4.0.0(electron@39.8.6)':
'@electron-toolkit/utils@4.0.0(electron@41.7.0)':
dependencies:
electron: 39.8.6
electron: 41.7.0
'@electron/asar@3.4.1':
dependencies:
@@ -7675,7 +7669,7 @@ snapshots:
'@types/electron-localshortcut@3.1.3':
dependencies:
electron: 39.8.6
electron: 41.7.0
transitivePeerDependencies:
- supports-color
@@ -7703,10 +7697,6 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
'@types/node@24.12.2':
dependencies:
undici-types: 7.16.0
@@ -8800,10 +8790,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@39.8.6:
electron@41.7.0:
dependencies:
'@electron/get': 2.0.3
'@types/node': 22.19.17
'@types/node': 24.12.2
extract-zip: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -11588,8 +11578,6 @@ snapshots:
sprintf-js: 1.1.3
util-deprecate: 1.0.2
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@6.24.1: {}
+3
View File
@@ -88,8 +88,11 @@ window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}";
window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}";
window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}";
window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}";
window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}";
window.FS_AUTO_DJ_ITEM_COUNT = "${FS_AUTO_DJ_ITEM_COUNT}";
window.FS_AUTO_DJ_MODE = "${FS_AUTO_DJ_MODE}";
window.FS_AUTO_DJ_SONG_STRATEGY = "${FS_AUTO_DJ_SONG_STRATEGY}";
window.FS_AUTO_DJ_TIMING = "${FS_AUTO_DJ_TIMING}";
window.FS_CSS_CONTENT = "${FS_CSS_CONTENT}";
+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',
+349 -15
View File
@@ -2,32 +2,48 @@
"action": {
"addToFavorites": "إضافة الى $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "إضافة الى $t(entity.playlist, {\"count\": 1})",
"clearQueue": "مسح قائمة الإنتظار",
"clearQueue": "مسح قائمة التشغيل",
"createPlaylist": "إنشاء $t(entity.playlist, {\"count\": 1})",
"deletePlaylist": "حذف $t(entity.playlist, {\"count\": 1})",
"deselectAll": "إلغاء تحديد الكل",
"editPlaylist": "تعديل $t(entity.playlist, {\"count\": 1})",
"goToPage": "اذهب الى صفحة",
"moveToNext": "الذهاب الى التالي",
"moveToBottom": "الذهاب الى الأسفل",
"moveToTop": "الذهاب الى الأعلى",
"goToPage": "اذهب الى الصفحة",
"moveToNext": "نقل إلى التالي",
"moveToBottom": "نقل إلى الأسفل",
"moveToTop": "نقل إلى الأعلى",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "حذف من $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "حذف من $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "حذف من قائمة الإنتظار",
"removeFromQueue": "حذف من قائمة التشغيل",
"setRating": "تحديد التقييم",
"toggleSmartPlaylistEditor": "تشغيل / إطفاء وضع التعديل لـ $t(entity.smartPlaylist)",
"viewPlaylists": "إظهار $t(entity.playlist, {\"count\": 2})",
"toggleSmartPlaylistEditor": "إظهار / إخفاء وضع التعديل لـ $t(entity.smartPlaylist)",
"viewPlaylists": "عرض $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "فتح في Last.fm",
"musicbrainz": "فتح في MusicBrainz"
"musicbrainz": "فتح في MusicBrainz",
"listenbrainz": "فتح في ListenBrainz",
"qobuz": "فتح في Qobuz",
"spotify": "فتح في Spotify"
},
"addOrRemoveFromSelection": "إضافة أو إزالة من الإختيارات",
"selectRangeOfItems": "اختر مجموعة من العناصر",
"goToCurrent": "الانتقال إلى العنصر الحالي",
"createRadioStation": "يخلق $t(entity.radioStation, {\"count\": 1})",
"createRadioStation": "إنشاء $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "يمسح $t(entity.radioStation, {\"count\": 1})",
"selectAll": "تحديد الكل"
"selectAll": "تحديد الكل",
"shuffle": "لخبط",
"shuffleAll": "لخبط الكل",
"shuffleSelected": "لخبط المحدد",
"collapseAllFolders": "اطو جميع المجلدات",
"expandAllFolders": "بسط الملفات",
"downloadStarted": "بدأ تحميل {{count}} عنصر",
"moveUp": "نقل إلى فوق",
"moveDown": "نقل إلى تحت",
"holdToMoveToTop": "اضغط مطولاً للنقل إلى الأعلى",
"holdToMoveToBottom": "اضغط مطولاً للنقل إلى الأسفل",
"moveItems": "نقل العناصر",
"viewMore": "عرض المزيد",
"openApplicationDirectory": "فتح مجلد التطبيق"
},
"common": {
"action_zero": "عملية",
@@ -39,13 +55,13 @@
"add": "إضافة",
"additionalParticipants": "مشاركين إضافيين",
"newVersion": "تم تثبيت تحديث جديد {{version}}",
"viewReleaseNotes": "عرض معلومات الإصدار",
"viewReleaseNotes": "عرض ملاحظات الإصدار",
"albumGain": "مستوى صوت الألبوم",
"albumPeak": "اعلى مستوى للألبوم",
"areYouSure": "هل أنت متأكد؟",
"ascending": "تصاعدي",
"backward": "خلف",
"biography": "سيرة",
"biography": "السيرة",
"bitDepth": "عمق البت",
"bitrate": "معدل البت (البت ريت)",
"bpm": "نبضة في الدقيقة",
@@ -141,7 +157,35 @@
"unknown": "غير معروف",
"version": "الإصدار",
"year": "السنة",
"yes": "نعم"
"yes": "نعم",
"explicitStatus": "حالة المحتوى الصريح",
"countSelected": "{{count}} عنصر محدد",
"back": "للخلف",
"doNotShowAgain": "لا تظهر هذا مجدداً",
"view": "عرض",
"example": "مثال",
"externalLinks": "روابط الخارجية",
"openFolder": "فتح المجلد",
"faster": "أسرع",
"filter_single": "فردي",
"filter_multiple": "متعدد",
"grouping": "مجموعات",
"mood": "مزاج",
"numberOfResults": "{{numberOfResults}} نتيجة",
"noFilters": "لا توجد فلاتر معينة",
"private": "خاص",
"public": "عام",
"retry": "إعادة المحاولة",
"recordLabel": "شركة التسجيل",
"releaseType": "نوع الإصدار",
"rename": "إعادة تسمية",
"slower": "أبطأ",
"sort": "فرز",
"explicit": "صريح",
"clean": "نظيف",
"gridRows": "صفوف الشبكة",
"tableColumns": "أعمدة الجدول",
"newVersionAvailable": "هناك نسخة جديدة متاحة"
},
"entity": {
"album_zero": "الالبوم",
@@ -155,6 +199,296 @@
"albumArtist_two": "فنان الالبومين",
"albumArtist_few": "فنان الالبومات",
"albumArtist_many": "فنان الالبومات",
"albumArtist_other": "فنان الالبومات"
"albumArtist_other": "فنان الالبومات",
"albumArtistCount_zero": "{{count}} فنان الالبوم",
"albumArtistCount_one": "{{count}} فنان الالبوم",
"albumArtistCount_two": "{{count}} فنان الالبومين",
"albumArtistCount_few": "{{count}} فنان الالبومات",
"albumArtistCount_many": "{{count}} فنان الالبومات",
"albumArtistCount_other": "{{count}} فنان الالبومات",
"albumWithCount_zero": "{{count}} البوم",
"albumWithCount_one": "{{count}} البوم",
"albumWithCount_two": "{{count}} البومين",
"albumWithCount_few": "{{count}} البومات",
"albumWithCount_many": "{{count}} البومات",
"albumWithCount_other": "{{count}} البومات",
"radioStation_zero": "محطة راديو",
"radioStation_one": "محطة راديو",
"radioStation_two": "محطتان راديو",
"radioStation_few": "محطات راديو",
"radioStation_many": "محطات راديو",
"radioStation_other": "محطات راديو",
"radioStationWithCount_zero": "{{count}} محطة راديو",
"radioStationWithCount_one": "{{count}} محطة راديو",
"radioStationWithCount_two": "{{count}} محطتان راديو",
"radioStationWithCount_few": "{{count}} محطات راديو",
"radioStationWithCount_many": "{{count}} محطات راديو",
"radioStationWithCount_other": "{{count}} محطات راديو",
"artist_zero": "فنان",
"artist_one": "فنان",
"artist_two": "فنانان",
"artist_few": "فنانين",
"artist_many": "فنانين",
"artist_other": "فنانين",
"artistWithCount_zero": "{{count}} فنان",
"artistWithCount_one": "{{count}} فنان",
"artistWithCount_two": "{{count}} فنانان",
"artistWithCount_few": "{{count}} فنانين",
"artistWithCount_many": "{{count}} فنانين",
"artistWithCount_other": "{{count}} فنانين",
"favorite_zero": "مفضلة",
"favorite_one": "مفضلة",
"favorite_two": "مفضلتان",
"favorite_few": "مفضلات",
"favorite_many": "مفضلات",
"favorite_other": "مفضلات",
"folder_zero": "مجلد",
"folder_one": "مجلد",
"folder_two": "مجلدان",
"folder_few": "مجلدات",
"folder_many": "مجلدات",
"folder_other": "مجلدات",
"folderWithCount_zero": "{{count}} مجلد",
"folderWithCount_one": "{{count}} مجلد",
"folderWithCount_two": "{{count}} مجلدان",
"folderWithCount_few": "{{count}} مجلدات",
"folderWithCount_many": "{{count}} مجلدات",
"folderWithCount_other": "{{count}} مجلدات",
"genre_zero": "نوع",
"genre_one": "نوع",
"genre_two": "نوعان",
"genre_few": "أنواع",
"genre_many": "أنواع",
"genre_other": "أنواع",
"genreWithCount_zero": "{{count}} نوع",
"genreWithCount_one": "{{count}} نوع",
"genreWithCount_two": "{{count}} نوعان",
"genreWithCount_few": "{{count}} أنواع",
"genreWithCount_many": "{{count}} أنواع",
"genreWithCount_other": "{{count}} أنواع",
"playlist_zero": "قائمة تشغيل",
"playlist_one": "قائمة تشغيل",
"playlist_two": "قائمتان تشغيل",
"playlist_few": "قوائم تشغيل",
"playlist_many": "قوائم تشغيل",
"playlist_other": "قوائم تشغيل",
"play_zero": "{{count}} قائمة تشغيل",
"play_one": "{{count}} قائمة تشغيل",
"play_two": "{{count}} قائمتان تشغيل",
"play_few": "{{count}} قوائم تشغيل",
"play_many": "{{count}} قوائم تشغيل",
"play_other": "{{count}} قوائم تشغيل",
"playlistWithCount_zero": "{{count}} قائمة تشغيل",
"playlistWithCount_one": "{{count}} قائمة تشغيل",
"playlistWithCount_two": "{{count}} قائمتان تشغيل",
"playlistWithCount_few": "{{count}} قوائم تشغيل",
"playlistWithCount_many": "{{count}} قوائم تشغيل",
"playlistWithCount_other": "{{count}} قوائم تشغيل",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) قائمة تشغيل ذكية",
"track_zero": "مقطع",
"track_one": "مقطع",
"track_two": "مقطعان",
"track_few": "مقاطع",
"track_many": "مقاطع",
"track_other": "مقاطع",
"song_zero": "أغنية",
"song_one": "أغنية",
"song_two": "أغنيتان",
"song_few": "أغاني",
"song_many": "أغاني",
"song_other": "أغاني",
"trackWithCount_zero": "{{count}} مقطع",
"trackWithCount_one": "{{count}} مقطع",
"trackWithCount_two": "{{count}} مقطعان",
"trackWithCount_few": "{{count}} مقاطع",
"trackWithCount_many": "{{count}} مقاطع",
"trackWithCount_other": "{{count}} مقاطع"
},
"error": {
"apiRouteError": "تعذّر توجيه الطلب",
"audioDeviceFetchError": "حصل خطأ أثناء محاولة الحصول على أجهزة الصوت",
"authenticationFailed": "فشلت المصادقة",
"badAlbum": "أنت ترى هذة الصفحة لأن هذه الأغنية ليست جزءاً من ألبوم. على الأرجح تظهر لك هذه المشكلة إذا كان لديك أغنية في المستوى الأعلى من مجلد الموسيقى. يقوم Jellyfin بتجميع الأغاني فقط إذا كانت داخل مجلد",
"credentialsRequired": "يتطلب بيانات اعتماد",
"genericError": "حدث خطأ",
"loginRateError": "تجاوزت الحد لمحاولات الدخول. حاول مجدداً بعد بضع ثوان",
"mpvRequired": "يتطلب MPV",
"multipleServerSaveQueueError": "قائمة التشغيل تحتوي على أغنية أو أكثر من خادم مختلف. هذا غير مدعوم",
"networkError": "حصل خطأ في الشبكة",
"noNetwork": "الخادم غير متوفر",
"noNetworkDescription": "تعذر الإتصال بالخادم",
"notificationDenied": "تم رفض أذن الإشعارات. هذا الإعداد لن يكون له أي تأثير",
"openError": "تعذر فتح الملف",
"playbackError": "حدث خطأ أثناء محاولة تشغيل الوسائط",
"playbackPausedDueToError": "تم ايقاف التشغيل بسبب خطأ",
"remoteDisableError": "حدث خطأ أثناء محاولة $t(common.disable) الخادم البعيد",
"remoteEnableError": "حدث خطأ أثناء محاولة $t(common.enable) الخادم البعيد",
"remotePortError": "حدث خطأ أثناء محاولة تعيين الخادم البعيد",
"remotePortWarning": "أعد تشغيل الخادم لتطبيق المنفذ الجديد",
"saveQueueFailed": "فشل حفظ قائمة التشغيل",
"serverLockSingleServer": "فقط خادم واحد متاح إذا الخادم مقفل",
"serverNotSelectedError": "لم يتم اختيار أي خادم",
"serverRequired": "يتطلب خادم",
"sessionExpiredError": "انتهت صلاحية جلستك",
"systemFontError": "حدث خطأ أثناء محاولة الحصول على خطوط النظام",
"settingsSyncError": "تم اكتشاف تعارضات بين إعدادات العارض والعملية الرئيسية. أعد تشغيل التطبيق لتطبيق التغييرات",
"invalidJson": "JSON غير صالح",
"invalidServer": "خادم غير صالح",
"localFontAccessDenied": "تم رفض الوصول إلى الخطوط المحلية"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "و",
"matchOr": "أو",
"biography": "السيرة",
"bitrate": "معدل البت (البت ريت)",
"bpm": "نبضة في الدقيقة",
"comment": "تعليق",
"communityRating": "تقييم المجتمع",
"criticRating": "تقييم الناقد",
"dateAdded": "تاريخ الإضافة",
"disc": "قرص",
"duration": "المدة",
"favorited": "مفضل",
"fromYear": "من سنة",
"id": "معرف",
"isFavorited": "مفضل",
"isPublic": "عام",
"isRated": "مقيم",
"isRecentlyPlayed": "تم التشغيل حديثاً",
"lastPlayed": "أخر تشغيل",
"mostPlayed": "أكثر تشغيل",
"name": "الأسم",
"note": "الملاحظة",
"path": "المسار",
"playCount": "عدد التشغيلات",
"random": "عشوائي",
"rating": "التقييم",
"recentlyAdded": "مضاف حديثاً",
"recentlyPlayed": "تم التشغيل حديثاً",
"recentlyUpdated": "محدث حديثاً",
"releaseDate": "تاريخ الإصدار",
"releaseYear": "سنة الإصدار",
"search": "بحث",
"songCount": "عدد الأغاني",
"sortName": "أسم الفرز",
"title": "العنوان",
"toYear": "إلى سنة",
"trackNumber": "مقطع",
"isCompilation": "تجميعة"
},
"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": "يبدأ بـ"
},
"form": {
"addServer": {
"error_savePassword": "حدث خطأ أثناء محاولة حفظ كلمة السر",
"input_legacyAuthentication": "تفعيل المصادقة القديمة",
"input_name": "أسم الخادم",
"input_password": "كلمة السر",
"input_preferRemoteUrl": "تفضيل رابط عام",
"input_remoteUrl": "رابط عام",
"input_savePassword": "حفظ كلمة السر",
"input_url": "الرابط",
"input_username": "أسم المستخدم",
"success": "تمت إضافة الخادم بنجاح",
"title": "إضافة خادم",
"input_preferInstantMix": "تفضيل الميكس الفوري",
"input_preferInstantMixDescription": "استخدم الميكس الفوري فقط للحصول على أغاني مشابهة. مفيد إذا كان لديك إضافات تعدّل هذا السلوك",
"input_remoteUrlPlaceholder": "اختياري: عنوان URL عام للميزات الخارجية"
},
"largeFetchConfirmation": {
"title": "أضف العناصر إلى قائمة التشغيل",
"description": "سيقوم هذا الإجراء بإضافة جميع العناصر في العرض المفلتر الحالي"
},
"addToPlaylist": {
"input_skipDuplicates": "تخطي العناصر المكررة",
"title": "أضف إلى $t(entity.playlist, {\"count\": 1})",
"create": "إنشاء $t(entity.playlist, {\"count\": 1}) {{playlist}}"
},
"createPlaylist": {
"input_public": "عام"
},
"createRadioStation": {
"input_homepageUrl": "رابط الرئيسية",
"input_name": "الأسم",
"input_streamUrl": "رابط البث",
"success": "تم إنشاء محطة راديو جديدة بنجاح",
"title": "إنشاء محطة راديو"
},
"editRadioStation": {
"success": "تم تحديث محطة الراديو بنجاح"
},
"deletePlaylist": {
"input_confirm": "أكتب أسم $t(entity.playlist, {\"count\": 1}) للتأكيد",
"success": "تم حذف $t(entity.playlist, {\"count\": 1}) بنجاح",
"title": "حذف $t(entity.playlist, {\"count\": 1})"
},
"editPlaylist": {
"success": "تم تحديث $t(entity.playlist, {\"count\": 1}) بنجاح",
"title": "تعديل $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "لسبب ما، لا يكشف Jellyfin عما إذا كانت قائمة التشغيل عامة أم لا. إذا كنت ترغب في إبقائها عامة، يرجى التأكد من تحديد الخيار التالي"
},
"lyricsExport": {
"export": "تصدير الكلمات",
"input_synced": "تصدير الكلمات المتزامنة"
},
"lyricSearch": {
"title": "البحث بالكلمات"
},
"queryEditor": {
"input_optionMatchAll": "تطابق الجميع",
"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": "الأغاني المفضلة"
}
}
}
+62 -26
View File
@@ -1,7 +1,7 @@
{
"page": {
"sidebar": {
"myLibrary": "La meva llibreria",
"myLibrary": "La meva mediateca",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})",
@@ -340,7 +340,9 @@
"filter_multiple": "Multi",
"rename": "Reanomena",
"newVersionAvailable": "Hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats"
"numberOfResults": "{{numberOfResults}} resultats",
"back": "Enrere",
"openFolder": "Obre la carpeta"
},
"entity": {
"album_one": "Àlbum",
@@ -461,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",
@@ -494,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",
@@ -557,7 +564,9 @@
"selectRangeOfItems": "Selecciona un interval d'elements",
"selectAll": "Selecciona-ho tot",
"openApplicationDirectory": "Obre el directori de l'aplicació",
"goToCurrent": "Anar a l'element actual"
"goToCurrent": "Anar a l'element actual",
"collapseAllFolders": "Replega totes les carpetes",
"expandAllFolders": "Expandeix totes les carpetes"
},
"setting": {
"language_description": "Estableix l'idioma de l'aplicació ($t(common.restartRequired))",
@@ -623,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}}",
@@ -804,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",
@@ -824,9 +833,8 @@
"notify_description": "Mostra notificacions quan la cançó actual canviï",
"transcode": "Activa la transcodificació",
"autoDJ": "DJ automàtic",
"autoDJ_description": "Afegeix cançons similars a la cua automàticament",
"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",
@@ -921,8 +929,8 @@
"primaryShade_description": "Substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari",
"playerItemConfiguration_description": "Configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "Configuració d'elements del jugador",
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "mostra enllaços a ListenBrainz",
"listenbrainz_description": "Mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "Mostra enllaços a ListenBrainz",
"qobuz_description": "Mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
"qobuz": "Mostra enllaços a Qobuz",
"spotify_description": "Mostra enllaços a Spotify a les pàgines d'artista/àlbum",
@@ -930,25 +938,52 @@
"nativeSpotify_description": "Obre amb Spotify en lloc del vostre navegador",
"nativeSpotify": "Fes servir Spotify",
"playerbarWaveformStretch": "Extensió de la forma d'ona",
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
"playerbarWaveformStretch_description": "Estén la forma d'ona per omplir l'espai disponible",
"sidePlayQueueLayout": "Disposició de la cua de reproducció lateral",
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
"sidePlayQueueLayout_optionVertical": "vertical",
"sidePlayQueueLayout_description": "Estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "Horitzontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"waveformLoadingDelay": "Demora de càrrega de la forma d'ona",
"waveformLoadingDelay_description": "Demora en segons abans de carregar la forma d'ona. Incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
"preventSuspendOnPlayback_description": "Evita que l'aplicació quedi suspesa mentre es reprodueix música",
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció"
"preventSuspendOnPlayback": "Evita la suspensió durant la reproducció",
"hotkey_listShowPlayingSong": "Mostra la cançó en reproducció a la llista",
"sidebarPlaylistFolders_description": "Create una vista de carpeta per lliste de reproducció que inclogui el separador configurat al nom",
"sidebarPlaylistFolders": "Habilita les carpetes",
"sidebarPlaylistFolderSeparator_description": "Caràcter (o cadena) que separa els nivells de carpetes al nom d'una llista de reproducció",
"sidebarPlaylistFolderSeparator": "Separador de carpetes",
"sidebarPlaylistFolderView_description": "Com es mostren les carpetes a la barra lateral",
"sidebarPlaylistFolderView": "Vista de carpeta",
"sidebarPlaylistFolderView_optionSingle": "Carpeta única",
"sidebarPlaylistFolderView_optionTree": "Vista d'arbre",
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegació",
"sidebarPlaylistFolderTreeIndent_description": "Píxels de sagnat per cada nivell de l'arbre",
"sidebarPlaylistFolderTreeIndent": "Sagnat de l'arbre",
"sidebarPlaylistFolderTreeLineColor_description": "Color de les línies connectores de l'arbre (deixeu-ho en blanc per fer servir el valor predeterminat)",
"sidebarPlaylistFolderTreeLineColor": "Color de la línia de l'arbre",
"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",
"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": {
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"genre": "$t(entity.genre, {\"count\": 1})",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"albumCount": "Àlbums",
"artist": "Artista",
"channels": "Canals",
"codec": "dec",
"genre": "Gènere",
"size": "Mida",
"songCount": "Pistes",
"releaseYear": "Any",
"playCount": "Reproduccions",
"releaseDate": "Data de llançament",
@@ -966,8 +1001,8 @@
"path": "Ruta",
"rating": "Qualificació",
"title": "Títol",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"bitDepth": "Profunditat de bits",
"sampleRate": "Freqüència de mostreig",
"owner": "Propietari"
},
"config": {
@@ -1146,7 +1181,8 @@
"sleepTimer_timeRemaining": "Queden {{time}}",
"sleepTimer_setCustom": "Configura el temporitzador",
"sleepTimer_cancel": "Cancel·la el temporitzador",
"albumRadio": "Ràdio d'àlbums"
"albumRadio": "Ràdio d'àlbums",
"scrobbleForceSubmit": "Força l'scrobble"
},
"error": {
"credentialsRequired": "Credencials requerides",
+21 -7
View File
@@ -234,7 +234,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 +296,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í",
@@ -344,9 +344,8 @@
"playerFilters_description": "Vynechat skladby z přidání do fronty na základě následujících kritérií",
"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_description": "Automaticky přidávat podobné skladby do fronty",
"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",
@@ -448,7 +447,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})",
@@ -624,7 +632,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": {
@@ -1123,7 +1132,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"
-1
View File
@@ -695,7 +695,6 @@
},
"setting": {
"autoDJ": "Auto-DJ",
"autoDJ_description": "Tilføj automatisk lignende sange til køen",
"autoDJ_itemCount": "Antal elementer",
"autoDJ_itemCount_description": "Antallet af elementer der forsøges tilføjet til køen, når auto-DJ er aktiveret",
"autoDJ_timing": "Tidspunkt",
+62 -51
View File
@@ -13,8 +13,8 @@
"removeFromPlaylist": "Aus $t(entity.playlist, {\"count\": 1}) entfernen",
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) anzeigen",
"refresh": "$t(common.refresh)",
"removeFromQueue": "Aus wiedergabeliste entfernen",
"setRating": "Bewerten",
"removeFromQueue": "Aus Wiedergabeliste entfernen",
"setRating": "Bewertung setzen",
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
"openIn": {
@@ -41,12 +41,14 @@
"selectRangeOfItems": "Wählen sie eine reihe von elementen",
"holdToMoveToTop": "Halten um nach oben zu bewegen",
"holdToMoveToBottom": "Halten um nach unten zu bewegen",
"goToCurrent": "Zu aktuellem eintrag wechseln"
"goToCurrent": "Zu aktuellem Eintrag wechseln",
"collapseAllFolders": "Alle Ordner einklappen",
"expandAllFolders": "Alle Ordner ausklappen"
},
"common": {
"backward": "Zurück",
"increase": "Erhöhen",
"rating": "Wertung",
"rating": "Bewertung",
"bpm": "Bpm",
"refresh": "Aktualisieren",
"unknown": "Unbekannt",
@@ -165,9 +167,10 @@
"rename": "Umbenennen",
"filter_single": "Einzeln",
"filter_multiple": "Mehrfach",
"retry": "Wiederholen",
"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",
@@ -177,7 +180,7 @@
"remotePortError": "Beim Versuch, den Remote-Server-Port festzulegen, ist ein Fehler aufgetreten",
"serverRequired": "Server benötigt",
"authenticationFailed": "Authentifizierung fehlgeschlagen",
"apiRouteError": "Anforderung kann nicht weitergeleitet werden",
"apiRouteError": "Anfrage kann nicht weitergeleitet werden",
"genericError": "Ein Fehler ist aufgetreten",
"credentialsRequired": "Anmeldeinformationen erforderlich",
"sessionExpiredError": "Deine Sitzung ist abgelaufen",
@@ -189,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",
@@ -218,7 +221,7 @@
"recentlyAdded": "Kürzlich hinzugefügt",
"note": "Hinweis",
"name": "Name",
"dateAdded": "Datum hinzugefügt",
"dateAdded": "Hinzugefügt am",
"releaseDate": "Veröffentlichungsdatum",
"albumCount": "$t(entity.album, {\"count\": 2}) anzahl",
"communityRating": "Community-wertung",
@@ -248,7 +251,8 @@
"artist": "$t(entity.artist, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "Und",
"matchOr": "Oder"
"matchOr": "Oder",
"sortName": "Sortierungsname"
},
"form": {
"deletePlaylist": {
@@ -306,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",
@@ -324,12 +328,12 @@
"successMustClick": "Freigabe erfolgreich erstellt. Hier klicken um diese zu öffnen"
},
"privateMode": {
"enabled": "Privatmodus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
"disabled": "Privatmodus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben",
"title": "Privatmodus"
"enabled": "Privater Modus aktiviert, Wiedergabe-Status wird externen Quellen nicht preisgegeben",
"disabled": "Privater Modus deaktiviert, Wiedergabe-Status wird externen Quellen preisgegeben",
"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": {
@@ -344,7 +348,7 @@
"input_played": "Wiedergabefilter"
},
"saveQueue": {
"success": "Wiedergabeliste auf server gespeichert"
"success": "Wiedergabeliste auf Server gespeichert"
},
"createRadioStation": {
"success": "Radiosender erfolgreich erstellt",
@@ -355,7 +359,7 @@
},
"lyricsExport": {
"input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren",
"export": "Liedtext exportieren",
"input_synced": "Synchronisierte songtexte exportieren"
},
"editRadioStation": {
@@ -365,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",
@@ -539,19 +543,19 @@
"selectServer": "Server auswählen",
"version": "Version {{version}}",
"manageServers": "Server verwalten",
"expandSidebar": "Seitenleiste erweitern",
"expandSidebar": "Seitenleiste ausklappen",
"collapseSidebar": "Seitenleiste einklappen",
"openBrowserDevtools": "Browser-entwicklungswerkzeuge öffnen",
"goBack": "Gehe zurück",
"goForward": "Gehe vorwärts",
"settings": "$t(common.setting, {\"count\": 2})",
"quit": "$t(common.quit)",
"privateModeOff": "Privatmodus deaktivieren",
"privateModeOn": "Privatmodus aktivieren",
"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",
@@ -678,9 +682,9 @@
"topSongs": "Toplieder",
"relatedArtists": "Ähnliche $t(entity.artist, {\"count\": 2})",
"groupingTypeAll": "Alle veröffentlichungsformate",
"groupingTypePrimary": "Primäre veröffentlichungsformate",
"favoriteSongs": "Lieblingssongs",
"favoriteSongsFrom": "Liebslingssongs von {{title}}",
"groupingTypePrimary": "Primäre Veröffentlichungsformate",
"favoriteSongs": "Lieblingslieder",
"favoriteSongsFrom": "Liebslingslieder von {{title}}",
"topSongsCommunity": "Community",
"topSongsPersonal": "Persönlich"
},
@@ -711,7 +715,7 @@
},
"windowBar": {
"paused": "(Pausiert) ",
"privateMode": "(Privater modus)"
"privateMode": "(Privater Modus)"
},
"collections": {
"saveAsCollection": "Als sammlung speichern",
@@ -758,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",
@@ -897,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",
@@ -919,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",
@@ -947,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",
@@ -975,11 +979,10 @@
"logLevel_optionError": "Fehler",
"logLevel_optionInfo": "Info",
"logLevel_optionWarn": "Warnung",
"autoDJ_description": "Füge automatisch ähnliche Lieder der Wiedergabeliste hinzu",
"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",
@@ -1020,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",
@@ -1062,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",
@@ -1109,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",
@@ -1131,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",
+19 -4
View File
@@ -92,6 +92,7 @@
"expand": "Expand",
"example": "Example",
"externalLinks": "External links",
"openFolder": "Open folder",
"faster": "Faster",
"favorite": "Favorite",
"filter_one": "Filter",
@@ -415,6 +416,11 @@
},
"shuffleAll": {
"title": "Play random",
"input_kind_albums": "Albums",
"input_kind_songs": "Songs",
"input_kind": "Random picks",
"input_limit_albums": "How many albums?",
"input_limit_songs": "How many songs?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "How many songs?",
"input_minYear": "From year",
@@ -693,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",
@@ -731,11 +738,19 @@
},
"setting": {
"autoDJ": "Auto DJ",
"autoDJ_description": "Automatically add similar songs to the queue",
"autoDJ_itemCount": "Item count",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue when auto DJ is enabled",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue",
"autoDJ_timing": "Timing",
"autoDJ_timing_description": "The number of songs remaining in the queue before auto DJ is triggered",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Albums",
"autoDJ_mode_description": "Choose to add either songs or entire albums to the queue",
"autoDJ_mode_songs": "Songs",
"autoDJ_enabled": "Enable Auto DJ",
"autoDJ_albumStrategy": "Album selection mode",
"autoDJ_songStrategy": "Song selection mode",
"autoDJ_strategy_option_library_random": "Random",
"autoDJ_strategy_option_similar": "Similar",
"autosave": "Automatically save play queue",
"autosave_description": "Enable automatically saving the play queue to your server. This is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.",
"autosaveCount": "Automatic play queue save frequency",
@@ -785,7 +800,7 @@
"crossfadeDuration": "Crossfade duration",
"crossfadeStyle": "Crossfade style",
"crossfadeStyle_description": "Select the crossfade style to use for the audio player",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization. Desktop: feishin reads and writes custom.css in the app config directory and reloads it when the file changes",
"customCss": "Custom CSS",
"customCssEnable_description": "Allow for writing custom CSS",
"customCssEnable": "Enable custom CSS",
@@ -1079,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",
+21 -7
View File
@@ -235,7 +235,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 +296,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",
@@ -344,9 +344,8 @@
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
"autoDJ": "DJ Automático",
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
"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",
@@ -448,7 +447,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})",
@@ -624,7 +632,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",
@@ -1014,7 +1023,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"
+316
View File
@@ -0,0 +1,316 @@
{
"action": {
"addToFavorites": "Lisa üksusesse $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "Lisa üksusesse $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "Lisa või eemalda valikust",
"selectRangeOfItems": "Vali mitu üksust korraga",
"clearQueue": "Tühjenda järjekord",
"goToCurrent": "Mine praeguse üksuse juurde",
"collapseAllFolders": "Ahenda kõik kaustad",
"expandAllFolders": "Laienda kõik kaustad",
"createPlaylist": "Loo $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "Loo $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "Kustuta $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "Kustuta $t(entity.radioStation, {\"count\": 1})",
"selectAll": "Vali kõik",
"deselectAll": "Tühista kõigi valik",
"downloadStarted": "{{count}} üksuse allalaadimine algas",
"editPlaylist": "Muuda $t(entity.playlist, {\"count\": 1})",
"goToPage": "Mine lehele",
"moveToNext": "Järgmine",
"moveToBottom": "Liiguta lõppu",
"moveToTop": "Liiguta algusesse",
"moveUp": "Liigu üles",
"moveDown": "Liigu alla",
"holdToMoveToTop": "Hoia algusesse liigutamiseks",
"holdToMoveToBottom": "Hoia lõppu liigutamiseks",
"moveItems": "Liiguta üksusi",
"shuffle": "Sega",
"shuffleAll": "Sega kõik",
"shuffleSelected": "Sega valitud",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "Eemalda üksusest $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "Eemalda üksusest $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "Eemalda järjekorrast",
"setRating": "Hinda",
"toggleSmartPlaylistEditor": "Lülita $t(entity.smartPlaylist) redaktor sisse/välja",
"viewPlaylists": "Vaata $t(entity.playlist, {\"count\": 2})",
"viewMore": "Vaata rohkem",
"openApplicationDirectory": "Ava rakenduste kataloog",
"openIn": {
"lastfm": "Ava Last.fm-is",
"listenbrainz": "Ava ListenBrainzis",
"musicbrainz": "Ava MusicBrainzis",
"qobuz": "Ava Qobuzis",
"spotify": "Ava Spotifys"
}
},
"common": {
"countSelected": "{{count}} valitud",
"explicitStatus": "Ebasündsa sisu olek",
"action_one": "Toiming",
"action_other": "Toimingud",
"add": "Lisa",
"additionalParticipants": "Teised osalejad",
"newVersion": "Uus versioon on paigaldatud ({{version}})",
"viewReleaseNotes": "Kuva väljalaskemärkmed",
"albumGain": "Albumi helitugevus (gain)",
"albumPeak": "Albumi tippnivoo",
"areYouSure": "Kas oled kindel?",
"ascending": "Kasvav",
"back": "Tagasi",
"backward": "Tagasi",
"biography": "Biograafia",
"bitDepth": "Bititihedus",
"bitrate": "Bitikiirus",
"bpm": "BPM",
"cancel": "Tühista",
"center": "Keskel",
"channel_one": "Kanal",
"channel_other": "Kanalid",
"clear": "Tühjenda",
"close": "Sulge",
"codec": "Koodek",
"collapse": "Ahenda",
"comingSoon": "Tulekul…",
"configure": "Seadista",
"confirm": "Kinnita",
"create": "Loo",
"currentSong": "Praegune $t(entity.track, {\"count\": 1})",
"decrease": "Vähenda",
"delete": "Kustuta",
"descending": "Kahanev",
"description": "Kirjeldus",
"disable": "Keela",
"disc": "Plaat",
"dismiss": "Peida",
"doNotShowAgain": "Ära seda enam näita",
"duration": "Kestus",
"view": "Vaata",
"edit": "Muuda",
"enable": "Luba",
"expand": "Laienda",
"example": "Näide",
"externalLinks": "Välised lingid",
"openFolder": "Ava kaust",
"faster": "Kiiremini",
"favorite": "Lemmik",
"filter_one": "Filter",
"filter_other": "Filtrid",
"filters": "Filtrid",
"filter_multiple": "Mitu",
"filter_single": "Üksik",
"forceRestartRequired": "Muudatuste rakendamiseks taaskäivita… taaskäivitamiseks sule teavitus",
"forward": "Edasi",
"gap": "Vahe",
"home": "Avaleht",
"increase": "Suurenda",
"left": "Vasak",
"limit": "Limiit",
"manage": "Halda",
"maximize": "Maksimeeri",
"menu": "Menüü",
"minimize": "Minimeeri",
"modified": "Muudetud",
"mbid": "MusicBrainz ID",
"grouping": "Rühmitamine",
"mood": "Meeleolu",
"name": "Nimi",
"no": "Ei",
"none": "Puudub",
"noResultsFromQuery": "Päring ei andnud vasteid",
"numberOfResults": "{{numberOfResults}} vastet",
"noFilters": "Seadistatud filtreid pole",
"note": "Märkus",
"ok": "Ok",
"owner": "Omanik",
"playerMustBePaused": "Mängija peab olema pausil",
"preview": "Eelvaade",
"previousSong": "Eelmine $t(entity.track, {\"count\": 1})",
"private": "Privaatne",
"public": "Avalik",
"quit": "Välju",
"random": "Juhuslik",
"rating": "Hinne",
"retry": "Proovi uuesti",
"recordLabel": "Plaadifirma",
"releaseType": "Väljaande tüüp",
"refresh": "Värskenda",
"reload": "Laadi uuesti",
"rename": "Nimeta ümber",
"reset": "Lähtesta",
"resetToDefault": "Taasta vaikeväärtused",
"restartRequired": "Vajalik on taaskäivitamine",
"right": "Parem",
"sampleRate": "Diskreetimissagedus",
"save": "Salvesta",
"saveAndReplace": "Salvesta ja asenda",
"saveAs": "Salvesta nimega",
"search": "Otsi",
"setting_one": "Säte",
"setting_other": "Sätted",
"slower": "Aeglasemalt",
"share": "Jaga",
"size": "Suurus",
"sort": "Järjesta",
"sortOrder": "Järjestus",
"tags": "Sildid",
"title": "Pealkiri",
"trackNumber": "Pala",
"trackGain": "Pala võimendus",
"trackPeak": "Pala tippväärtus",
"translation": "Tõlge",
"unknown": "Tundmatu",
"version": "Versioon",
"year": "Aasta",
"yes": "Jah",
"explicit": "Ebatsensuurne",
"clean": "Puhas",
"gridRows": "Ruudustiku ridu",
"tableColumns": "Tabeli veerge",
"itemsMore": "{{count}} veel",
"newVersionAvailable": "Saadaval on uus versioon",
"path": "Asukoht"
},
"entity": {
"album_one": "Album",
"album_other": "Albumid",
"albumArtist_one": "Albumi esitaja",
"albumArtist_other": "Albumi esitajad",
"albumArtistCount_one": "{{count}} albumi esitaja",
"albumArtistCount_other": "{{count}} albumi esitajat",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albumit",
"radioStation_one": "Raadiojaam",
"radioStation_other": "Raadiojaamad",
"radioStationWithCount_one": "{{count}} raadiojaam",
"radioStationWithCount_other": "{{count}} raadiojaama",
"artist_one": "Esitaja",
"artist_other": "Esitajad",
"artistWithCount_one": "{{count}} esitaja",
"artistWithCount_other": "{{count}} esitajat",
"favorite_one": "Lemmik",
"favorite_other": "Lemmikud",
"folder_one": "Kaust",
"folder_other": "Kaustad",
"folderWithCount_one": "{{count}} kaust",
"folderWithCount_other": "{{count}} kausta",
"genre_one": "Žanr",
"genre_other": "Žanrid",
"genreWithCount_one": "{{count}} žanr",
"genreWithCount_other": "{{count}} žanrit",
"playlist_one": "Esitusloend",
"playlist_other": "Esitusloendid",
"play_one": "{{count}} esitus",
"play_other": "{{count}} esitust",
"playlistWithCount_one": "{{count}} esitusloend",
"playlistWithCount_other": "{{count}} esitusloendit",
"smartPlaylist": "Nutikas $t(entity.playlist, {\"count\": 1})",
"track_one": "Rada",
"track_other": "Rajad",
"song_one": "Lugu",
"song_other": "Lood",
"trackWithCount_one": "{{count}} rada",
"trackWithCount_other": "{{count}} rada"
},
"error": {
"apiRouteError": "Päringut ei saanud edastada",
"audioDeviceFetchError": "Heliseadmete hankimisel tekkis viga",
"authenticationFailed": "Autentimine nurjus",
"badAlbum": "Näed seda lehte, kuna see lugu ei kuulu ühegi albumi alla. Kõige sagedamini juhtub see siis, kui lugu asub sinu muusikakausta juurkataloogis (otse muusikakaustas). Jellyfin rühmitab lood albumiteks ainult siis, kui need asuvad eraldi kaustas",
"badValue": "Kehtetu valik \"{{value}}\". Seda väärtust enam ei eksisteeri",
"credentialsRequired": "Nõutav on autentimine",
"endpointNotImplementedError": "Lõpp-punkt {{endpoint}} pole serveri {{serverType}} puhul toetatud",
"genericError": "Tekkis viga",
"invalidJson": "Vigane JSON",
"invalidServer": "Vigane server",
"localFontAccessDenied": "Juurdepääs kohalikele fontidele on keelatud",
"loginRateError": "Liiga palju sisselogimiskatseid, proovi mõne sekundi pärast uuesti",
"mpvRequired": "Vajalik on MPV",
"multipleServerSaveQueueError": "Esitusjärjekorras on lugusi, mis ei pärine praegusest serverist. See pole toetatud",
"networkError": "Tekkis võrguviga",
"noNetwork": "Server pole saadaval",
"noNetworkDescription": "Selle serveriga ei õnnestunud ühendust luua",
"notificationDenied": "Märguanded on keelatud. Sellel seadel pole mõju",
"openError": "Faili avamine nurjus",
"playbackError": "Meedia esitamisel tekkis viga",
"playbackPausedDueToError": "Taasesitus peatati vea tõttu",
"remoteDisableError": "Kaugserveri toimingu $t(common.disable) käigus tekkis viga",
"remoteEnableError": "Tõrge kaugserveri toimingul: $t(common.enable)",
"remotePortError": "Kaugserveri pordi määramisel tekkis viga",
"remotePortWarning": "Uue pordi rakendamiseks taaskäivita server",
"saveQueueFailed": "Järjekorra salvestamine nurjus",
"serverLockSingleServer": "Lukustatud serveri korral on lubatud ainult üks server",
"serverNotSelectedError": "Serverit pole valitud",
"serverRequired": "Vajalik on server",
"sessionExpiredError": "Su seanss on aegunud",
"systemFontError": "Süsteemifontide hankimisel ilmnes viga",
"settingsSyncError": "Esitaja (renderer) ja peaprotsessi seadete vahel leiti lahknevusi. Muudatuste rakendamiseks taaskäivita rakendus"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "ja",
"matchOr": "või",
"albumCount": "$t(entity.album, {\"count\": 2}) arv",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "Biograafia",
"bitrate": "Bitikiirus",
"bpm": "BPM",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "Kommentaar",
"communityRating": "Kogukonna hinne",
"criticRating": "Kriitikute hinne",
"dateAdded": "Lisatud",
"disc": "Plaat",
"duration": "Kestus",
"favorited": "Lemmikuks lisatud",
"fromYear": "Aastast",
"genre": "$t(entity.genre, {\"count\": 1})",
"id": "ID",
"isCompilation": "on kogumik",
"isFavorited": "on lemmik",
"isPublic": "on avalik",
"isRated": "on hinnatud",
"isRecentlyPlayed": "on hiljuti esitatud",
"lastPlayed": "Viimati esitatud",
"mostPlayed": "Enim esitatud",
"name": "Nimi",
"note": "Märkus",
"owner": "$t(common.owner)",
"path": "Asukoht",
"playCount": "Esituskordi",
"random": "Juhuslik",
"rating": "Hinne",
"recentlyAdded": "Viimati lisatud",
"recentlyPlayed": "Hiljuti esitatud",
"recentlyUpdated": "Hiljuti uuendatud",
"releaseDate": "Ilmumiskuupäev",
"releaseYear": "Ilmumisaasta",
"search": "Otsing",
"songCount": "Lugude arv",
"explicitStatus": "$t(common.explicitStatus)",
"title": "Pealkiri",
"sortName": "Sortimisnimi",
"trackNumber": "Lugu"
},
"filterOperator": {
"contains": "Sisaldab",
"endsWith": "Lõpeb",
"is": "On",
"isNot": "Ei ole",
"isGreaterThan": "On suurem kui",
"isLessThan": "On väiksem kui",
"matchesRegex": "Vastab regulaaravaldisele",
"notContains": "Ei sisalda",
"inTheRangeDate": "Jääb vahemikku (kuupäev)",
"inTheRange": "Jääb vahemikku",
"inTheLast": "Viimase",
"inPlaylist": "On",
"beforeDate": "On enne (kuupäeva)",
"before": "On enne",
"afterDate": "On pärast (kuupäeva)",
"after": "On pärast",
"notInPlaylist": "Ei ole"
}
}
-1
View File
@@ -658,7 +658,6 @@
"transcodeFormat": "Transkodetzeko formatua",
"queryBuilderCustomFields_inputLabel": "Etiketa",
"autoDJ": "DJ automatikoa",
"autoDJ_description": "Automatikoki gehitu antzeko abestiak ilaran",
"autoDJ_itemCount_description": "DJ automatikoa gaituta dagoenean ilaran gehitzen saiatu diren elementuen kopurua",
"autoDJ_timing_description": "DJ automatikoa aktibatu aurretik ilaran geratzen diren abestien kopurua",
"analyticsDisable": "Erabileran oinarritutako analisiei uko egin",
-1
View File
@@ -630,7 +630,6 @@
"releaseChannel_description": "Valitse vakaiden ja beetaversioiden välillä automaattisille päivityksille",
"discordDisplayType_artistname": "Artistin nimi / artistien nimet",
"autoDJ": "Auto DJ",
"autoDJ_description": "Lisää automaattisesti samanlaisia kappaleita jonoon",
"autoDJ_itemCount": "Kohteiden määrä",
"autoDJ_itemCount_description": "Jonoon lisättäväksi yritettyjen kohteiden määrä, kun auto DJ on käytössä",
"autoDJ_timing": "Ajastus"
-1
View File
@@ -812,7 +812,6 @@
"queryBuilderCustomFields": "Champs personnalisé",
"queryBuilderCustomFields_description": "Ajouter des champs personnalisés à utiliser dans les constructeurs de requêtes",
"autoDJ": "DJ auto",
"autoDJ_description": "Ajouter automatiquement des titres similaire à la file d'attente",
"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_timing": "Timing",
-1
View File
@@ -917,7 +917,6 @@
"queryBuilderCustomFields_description": "Egyéni mezők hozzáadása a lekérdezés-építőhöz",
"autoDJ": "Auto DJ",
"autoDJ_timing": "Időzítés",
"autoDJ_description": "Hasonló dalokat automatikusan hozzáad a műsorlistához",
"autoDJ_itemCount": "Elem szám",
"autoDJ_itemCount_description": "Az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma",
"autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma",
+126 -63
View File
@@ -16,7 +16,10 @@
"viewPlaylists": "Lihat $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "Buka di Last.fm",
"musicbrainz": "Buka di MusicBrainz"
"musicbrainz": "Buka di MusicBrainz",
"listenbrainz": "Buka di ListenBrainz",
"qobuz": "Buka di Qobuz",
"spotify": "Buka di Spotify"
},
"addToFavorites": "Tambahkan ke $t(entity.favorite, {\"count\": 2})",
"clearQueue": "Kosongkan antrian",
@@ -38,12 +41,14 @@
"shuffleSelected": "Acak yang dipilih",
"viewMore": "Lihat lebih banyak",
"openApplicationDirectory": "Buka direktori aplikasi",
"goToCurrent": "Pergi ke item saat ini"
"goToCurrent": "Pergi ke item saat ini",
"collapseAllFolders": "Ciutkan semua folder",
"expandAllFolders": "Bentangkan semua folder"
},
"common": {
"clear": "Bersihkan",
"action_other": "Aksi",
"codec": "Koded",
"codec": "Kodek",
"channel_other": "Saluran",
"duration": "Durasi",
"create": "Buat",
@@ -96,7 +101,7 @@
"random": "Acak",
"rating": "Penilaian",
"refresh": "Segarkan",
"reload": "Muat Ulang",
"reload": "Muat ulang",
"reset": "Reset",
"resetToDefault": "Reset ke default",
"restartRequired": "Restart diperlukan",
@@ -111,7 +116,7 @@
"sortOrder": "Urutkan",
"title": "Judul",
"trackNumber": "Pista",
"trackGain": "Gain pista",
"trackGain": "Gain trek",
"trackPeak": "Puncak lagu",
"unknown": "Tidak dikenal",
"version": "Versi",
@@ -156,7 +161,11 @@
"clean": "Bersih",
"gridRows": "Baris kisi",
"tableColumns": "Kolom tabel",
"itemsMore": "{{count}} lagi"
"itemsMore": "{{count}} lagi",
"back": "Kembali",
"grouping": "Pengelompokan",
"numberOfResults": "{{numberOfResults}} hasil",
"newVersionAvailable": "Versi baru tersedia"
},
"entity": {
"album_other": "Album",
@@ -173,7 +182,7 @@
"playlist_other": "Daftar Putar",
"play_other": "Putar {{count}}",
"playlistWithCount_other": "{{count}} daftar putar",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
"smartPlaylist": "Cerdas $t(entity.playlist, {\"count\": 1})",
"track_other": "Pista",
"song_other": "Lagu",
"trackWithCount_other": "{{count}} pista",
@@ -184,7 +193,7 @@
"apiRouteError": "Tidak dapat mengarahkan permintaan",
"audioDeviceFetchError": "Terjadi kesalahan saat mencoba mengambil perangkat audio",
"authenticationFailed": "Autentikasi gagal",
"badAlbum": "Anda melihat halaman ini karena lagu ini tidak termasuk dalam album. Masalah ini bisa terjadi jika Anda memiliki lagu di tingkat atas folder musik Anda. Jellyfin hanya mengelompokkan lagu jika mereka berada di dalam folder",
"badAlbum": "Anda melihat halaman ini karena lagu ini bukan bagian dari album. Masalah ini kemungkinan besar muncul jika Anda memiliki lagu di tingkat teratas folder musik Anda. Jellyfin hanya mengelompokkan trek jika berada di dalam folder",
"credentialsRequired": "Kredensial diperlukan",
"endpointNotImplementedError": "Endpoint {{endpoint}} tidak diimplementasikan untuk {{serverType}}",
"genericError": "Terjadi kesalahan",
@@ -211,7 +220,8 @@
"saveQueueFailed": "Gagal menyimpan antrean",
"settingsSyncError": "Ditemukan ketidaksesuaian antara pengaturan di perender dan proses utama. mulai ulang aplikasi untuk menerapkan perubahan",
"invalidJson": "JSON tidak valid",
"serverLockSingleServer": "Hanya satu server yang diizinkan ketika server dikunci"
"serverLockSingleServer": "Hanya satu server yang diizinkan ketika server dikunci",
"playbackPausedDueToError": "Pemutaran dijeda karena terjadi kesalahan"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -286,7 +296,8 @@
"success": "Ditambahkan $t(entity.trackWithCount, {\"count\": {{message}} }) ke $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "Tambahkan ke $t(entity.playlist, {\"count\": 1})",
"create": "Buat $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Cari $t(entity.playlist, {\"count\": 2}) atau ketik untuk membuat yang baru"
"searchOrCreate": "Cari $t(entity.playlist, {\"count\": 2}) atau ketik untuk membuat yang baru",
"noneAdded": "Tidak ada trek yang ditambahkan ke $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -321,12 +332,12 @@
"clearFilters": "Hapus filter"
},
"shareItem": {
"allowDownloading": "Izinkan unduhan",
"allowDownloading": "Izinkan pengunduhan",
"description": "Deskripsi",
"setExpiration": "Atur masa berlaku",
"success": "Tautan berbagi berhasil disalin ke papan klip (atau klik di sini untuk membuka)",
"expireInvalid": "Masa berlaku harus di masa depan",
"createFailed": "Tidak dapat membuat sumber daya berbagi (Apakah berbagi diaktifkan?)",
"success": "Tautan bagikan disalin ke papan klip (atau klik di sini untuk membukanya)",
"expireInvalid": "Masa berlaku harus di waktu mendatang",
"createFailed": "Gagal membuat bagikanan (apakah fitur berbagi diaktifkan?)",
"copyToClipboard": "Salin ke clipboard: Ctrl+C, enter",
"successMustClick": "Berbagi berhasil dibuat. klik di sini untuk membuka"
},
@@ -368,19 +379,22 @@
"enabled": "Mode pribadi diaktifkan, status pemutaran kini disembunyikan dari integrasi eksternal",
"disabled": "Mode pribadi dinonaktifkan, status pemutaran kini terlihat oleh integrasi eksternal yang diaktifkan",
"title": "Mode pribadi"
},
"editRadioStation": {
"success": "Stasiun radio berhasil diperbarui"
}
},
"page": {
"albumArtistDetail": {
"about": "Tentang {{artist}}",
"recentReleases": "Rilis terbaru",
"recentReleases": "Rilisan terbaru",
"viewDiscography": "Lihat diskografi",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) serupa",
"topSongs": "Lagu terbaik",
"topSongsFrom": "Lagu terbaik dari {{title}}",
"relatedArtists": "$t(entity.artist, {\"count\": 2}) terkait",
"topSongs": "Lagu teratas",
"topSongsFrom": "Lagu teratas dari {{title}}",
"viewAll": "Lihat semua",
"viewAllTracks": "Lihat semua $t(entity.track, {\"count\": 2})",
"appearsOn": "Tampil di",
"appearsOn": "Muncul di",
"groupingTypeAll": "Semua jenis rilis",
"groupingTypePrimary": "Jenis rilis utama",
"favoriteSongs": "Lagu favorit",
@@ -447,7 +461,7 @@
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Bagikan item",
"showDetails": "Lihat detail",
"showDetails": "Dapatkan info",
"moveToTop": "$t(action.moveToTop)",
"play": "$t(player.play)",
"moveItems": "$t(action.moveItems)",
@@ -470,7 +484,9 @@
"unsynchronized": "Tidak sinkronisasi",
"useImageAspectRatio": "Gunakan rasio aspek gambar",
"lyricOffset": "Offset lirik (ms)",
"lyricGap": "Jarak lirik"
"lyricGap": "Jarak lirik",
"lyricOpacityNonActive": "Opasitas lirik nonaktif",
"lyricScaleNonActive": "Skala lirik nonaktif"
},
"lyrics": "Lirik",
"related": "Terkait",
@@ -503,7 +519,7 @@
"itemDetail": {
"copyPath": "Salin jalur ke papan klip",
"copiedPath": "Jalur berhasil disalin",
"openFile": "Tampilkan lagu di pengelola file"
"openFile": "Tampilkan trek di pengelola file"
},
"playlist": {
"reorder": "Pengurutan ulang hanya diaktifkan saat mengurutkan berdasarkan ID"
@@ -631,19 +647,20 @@
"sleepTimer_off": "Mati",
"sleepTimer_timeRemaining": "{{time}} tersisa",
"sleepTimer_setCustom": "Atur pengatur waktu",
"sleepTimer_cancel": "Batalkan pengatur waktu"
"sleepTimer_cancel": "Batalkan pengatur waktu",
"scrobbleForceSubmit": "Paksa scrobble"
},
"setting": {
"accentColor": "Warna sorotan",
"accentColor_description": "Menetapkan warna sorotan aplikasi",
"albumBackground": "Gambar latar belakang album",
"albumBackground_description": "Tambahkan gambar latar belakang ke halaman album yang berisi sampul album",
"albumBackgroundBlur": "Ukuran blur gambar latar belakang album",
"albumBackgroundBlur_description": "Atur tingkat blur gambar latar belakang album",
"albumBackground_description": "Menambahkan gambar latar belakang untuk halaman album yang berisi sampul album",
"albumBackgroundBlur": "Ukuran keburaman gambar latar belakang album",
"albumBackgroundBlur_description": "Menyesuaikan tingkat keburaman yang diterapkan pada gambar latar belakang album",
"applicationHotkeys": "Tombol pintasan aplikasi",
"applicationHotkeys_description": "Menetapkan tombol pintasan aplikasi. centang untuk menjadikannya tombol pintasan global (desktop saja)",
"artistConfiguration": "Pengaturan halaman artis album",
"artistConfiguration_description": "Atur elemen apa yang ditampilkan dan urutannya di halaman artis album",
"artistConfiguration": "Konfigurasi halaman artis album",
"artistConfiguration_description": "Atur item apa saja yang ditampilkan, dan dalam urutan apa, pada halaman artis album",
"audioDevice": "Perangkat audio",
"audioDevice_description": "Pilih perangkat audio yang digunakan untuk pemutaran",
"audioExclusiveMode": "Mode audio eksklusif",
@@ -657,12 +674,12 @@
"windowBarStyle_description": "Pilih gaya bilah jendela",
"zoom": "Persentase zoom",
"zoom_description": "Tentukan persentase zoom aplikasi",
"clearCache_description": "'Pembersihan keras' Feishin. Untuk membersihkan cache Feishin, kosongkan cache browser (gambar yang disimpan dan elemen lainnya). Kredensial dan pengaturan server tetap terjaga",
"clearCache_description": "'Pembersihan keras' Feishin. Selain membersihkan cache Feishin, cache browser juga dikosongkan (gambar tersimpan dan aset lainnya). Kredensial server dan pengaturan tetap dipertahankan",
"clearQueryCache": "Bersihkan cache Feishin",
"clearQueryCache_description": "'Pembersihan lunak' Feishin. Ini akan menyegarkan daftar putar, metadata lagu, dan mengatur ulang lirik yang disimpan. Pengaturan, kredensial server, dan gambar cache tetap terjaga",
"clearCacheSuccess": "Cache berhasil dibersihkan",
"contextMenu": "Pengaturan menu konteks (klik kanan)",
"contextMenu_description": "Memungkinkan Anda menyembunyikan elemen yang ditampilkan dalam menu saat Anda klik kanan pada elemen. Elemen yang tidak dipilih akan disembunyikan",
"clearQueryCache_description": "'Pembersihan lunak' Feishin. Ini akan menyegarkan daftar putar, metadata trek, dan mengatur ulang lirik yang disimpan. Pengaturan, kredensial server, dan gambar yang dicache tetap dipertahankan",
"clearCacheSuccess": "Cache berhasil dikosongkan",
"contextMenu": "Konfigurasi menu konteks (klik kanan)",
"contextMenu_description": "Memungkinkan Anda menyembunyikan item yang ditampilkan di menu saat Anda mengeklik kanan suatu item. Item yang tidak dicentang akan disembunyikan",
"crossfadeDuration": "Durasi crossfade",
"crossfadeDuration_description": "Atur durasi efek crossfade",
"crossfadeStyle_description": "Pilih gaya crossfade yang digunakan oleh pemutar audio",
@@ -677,7 +694,7 @@
"discordApplicationId_description": "ID aplikasi untuk rich presence {{discord}} (default: {{defaultId}})",
"discordIdleStatus": "Tampilkan status tidak aktif dalam status aktivitas",
"discordIdleStatus_description": "Ketika diaktifkan, memperbarui status saat pemutar tidak aktif",
"discordListening": "Tampilkan status sebagai mendengarkan",
"discordListening": "Tampilkan status sebagai sedang mendengarkan",
"discordListening_description": "Tampilkan status sebagai mendengarkan alih-alih bermain",
"discordRichPresence_description": "Aktifkan status pemutaran di status aktivitas {{discord}}. Gambar tombol adalah: {{icon}}, {{playing}}, dan {{paused}}",
"discordUpdateInterval": "Interval pembaruan status aktivitas {{discord}}",
@@ -685,7 +702,7 @@
"enableRemote": "Aktifkan kontrol jarak jauh server",
"enableRemote_description": "Aktifkan kontrol jarak jauh server untuk memungkinkan perangkat lain mengontrol aplikasi",
"externalLinks": "Tampilkan tautan eksternal",
"externalLinks_description": "Izinkan untuk menampilkan tautan eksternal (Last.fm, MusicBrainz) di halaman artis/album",
"externalLinks_description": "Mengaktifkan penampilan tautan eksternal (Last.fm, MusicBrainz) pada halaman artis/album",
"exitToTray": "Keluar ke baki",
"exitToTray_description": "Keluar dari aplikasi ke baki sistem",
"followLyric": "Ikuti lirik saat ini",
@@ -702,14 +719,14 @@
"gaplessAudio_optionWeak": "Lemah (disarankan)",
"globalMediaHotkeys": "Tombol pintasan media global",
"globalMediaHotkeys_description": "Aktifkan atau nonaktifkan penggunaan tombol pintasan sistem media untuk mengontrol pemutaran",
"homeConfiguration": "Pengaturan halaman beranda",
"homeConfiguration_description": "Mengatur elemen mana yang ditampilkan dan urutannya di halaman beranda",
"homeFeature": "Karusel fitur beranda",
"homeFeature_description": "Mengontrol apakah karusel besar fitur ditampilkan di halaman beranda",
"homeConfiguration": "Konfigurasi halaman beranda",
"homeConfiguration_description": "Atur item apa saja yang ditampilkan, dan dalam urutan apa, pada halaman beranda",
"homeFeature": "Karusel unggulan beranda",
"homeFeature_description": "Mengatur apakah karusel unggulan besar ditampilkan di halaman beranda",
"hotkey_browserBack": "Mundur",
"hotkey_browserForward": "Maju",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) favorit",
"hotkey_favoritePreviousSong": "$t(common.previousSong) favorit",
"hotkey_favoriteCurrentSong": "Favoritkan $t(common.currentSong)",
"hotkey_favoritePreviousSong": "Favoritkan $t(common.previousSong)",
"hotkey_globalSearch": "Pencarian global",
"hotkey_localSearch": "Pencarian di halaman",
"hotkey_playbackNext": "Lagu berikutnya",
@@ -718,7 +735,7 @@
"hotkey_playbackPlayPause": "Putar / jeda",
"hotkey_playbackPrevious": "Lagu sebelumnya",
"hotkey_playbackStop": "Berhenti",
"hotkey_rate0": "Bersihkan penilaian",
"hotkey_rate0": "Hapus penilaian",
"hotkey_rate1": "Beri penilaian 1 bintang",
"hotkey_rate2": "Beri penilaian 2 bintang",
"hotkey_rate3": "Beri penilaian 3 bintang",
@@ -732,15 +749,15 @@
"hotkey_toggleQueue": "Ubah antrean",
"hotkey_toggleRepeat": "Toggle ulangi",
"hotkey_toggleShuffle": "Toggle acak",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) tidak favorit",
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) tidak favorit",
"hotkey_unfavoriteCurrentSong": "Batalkan favorit $t(common.currentSong)",
"hotkey_unfavoritePreviousSong": "Batalkan favorit $t(common.previousSong)",
"hotkey_volumeDown": "Turunkan volume",
"hotkey_volumeMute": "Senyapkan volume",
"hotkey_volumeUp": "Naikkan volume",
"hotkey_zoomIn": "Perbesar",
"hotkey_zoomOut": "Perkecil",
"imageAspectRatio": "Gunakan rasio aspek sampul asli",
"imageAspectRatio_description": "Jika diaktifkan, sampul akan ditampilkan dengan rasio aspek aslinya. Untuk seni yang tidak 1:1, ruang yang tersisa akan kosong",
"imageAspectRatio": "Gunakan rasio aspek asli sampul",
"imageAspectRatio_description": "Jika diaktifkan, sampul akan ditampilkan menggunakan rasio aspek aslinya. Untuk sampul yang tidak 1:1, ruang yang tersisa akan kosong",
"language_description": "Menetapkan bahasa untuk aplikasi ($t(common.restartRequired))",
"lastfmApiKey": "Kunci API untuk {{lastfm}}",
"lastfmApiKey_description": "Kunci API untuk {{lastfm}}. Diperlukan untuk sampul",
@@ -769,8 +786,8 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerbarOpenDrawer": "Buka pemutar ke layar penuh",
"playerbarOpenDrawer_description": "Izinkan mengklik bilah pemutar untuk membuka pemutar di layar penuh",
"playerbarOpenDrawer": "Tombol alih layar penuh bilah pemutar",
"playerbarOpenDrawer_description": "Memungkinkan bilah pemutar diklik untuk membuka pemutar layar penuh",
"remotePassword": "Kata sandi kontrol jarak jauh server",
"remotePassword_description": "Tentukan kata sandi untuk kontrol jarak jauh server. Kredensial ini dikirimkan dengan tidak aman secara default, jadi Anda harus menggunakan kata sandi unik untuk menghindari masalah",
"remotePort": "Port kontrol jarak jauh server",
@@ -829,7 +846,7 @@
"translationApiKey_description": "Kunci API untuk terjemahan (hanya endpoint layanan global)",
"translationTargetLanguage": "Bahasa tujuan penerjemahan",
"translationTargetLanguage_description": "Bahasa tujuan untuk penerjemahan",
"trayEnabled": "Tampilkan di area pemberitahuan",
"trayEnabled": "Tampilkan baki",
"trayEnabled_description": "Tampilkan/sembunyikan ikon/menu di area pemberitahuan. Jika dinonaktifkan, juga menonaktifkan meminimalkan/keluar ke baki",
"useSystemTheme": "Gunakan tema sistem",
"useSystemTheme_description": "Ikuti preferensi terang atau gelap yang ditetapkan oleh sistem",
@@ -838,14 +855,13 @@
"volumeWidth": "Lebar penggeser volume",
"volumeWidth_description": "Lebar penggeser volume",
"webAudio": "Gunakan audio web",
"clearCache": "Bersihkan cache browser",
"clearCache": "Kosongkan cache browser",
"disableLibraryUpdateOnStartup": "Nonaktifkan pemeriksaan versi baru saat startup",
"mpvExecutablePath": "Jalur executable mpv",
"playButtonBehavior_optionPlay": "$t(player.play)",
"sampleRate": "Rasio sampel",
"savePlayQueue": "Simpan antrean pemutaran",
"autoDJ": "DJ otomatis",
"autoDJ_description": "Tambahkan lagu serupa secara otomatis ke antrean",
"autoDJ": "DJ Otomatis",
"autoDJ_itemCount": "Jumlah item",
"autoDJ_itemCount_description": "Jumlah item yang dicoba ditambahkan ke antrean saat DJ otomatis diaktifkan",
"autoDJ_timing": "Waktu",
@@ -990,36 +1006,76 @@
"playerItemConfiguration": "Konfigurasi item pemutar",
"sidebarPlaylistListFilterRegex_description": "Sembunyikan playlist di bilah sisi yang cocok dengan ekspresi reguler ini",
"sidebarPlaylistListFilterRegex_placeholder": "Mis. ^daily mix.*",
"sidebarPlaylistListFilterRegex": "Regex filter playlist"
"sidebarPlaylistListFilterRegex": "Regex filter playlist",
"autosave": "Simpan antrean putar secara otomatis",
"autosave_description": "Aktifkan penyimpanan otomatis antrean putar ke server Anda. Ini hanya dimungkinkan saat menggunakan Navidrome/Subsonic, dan Anda tidak dapat memiliki antrean putar campuran.",
"autosaveCount": "Frekuensi penyimpanan otomatis antrean putar",
"autosaveCount_description": "Berapa banyak perubahan trek sebelum antrean disimpan. 1 (minimum) berarti setiap pergantian lagu",
"hotkey_listShowPlayingSong": "Tampilkan lagu yang sedang diputar dalam daftar",
"listenbrainz_description": "Tampilkan tautan ke ListenBrainz pada halaman artis/album",
"listenbrainz": "Tampilkan tautan ListenBrainz",
"qobuz_description": "Tampilkan tautan ke Qobuz pada halaman artis/album",
"qobuz": "Tampilkan tautan Qobuz",
"spotify_description": "Tampilkan tautan ke Spotify pada halaman artis/album",
"spotify": "Tampilkan tautan Spotify",
"nativeSpotify_description": "Buka di aplikasi Spotify alih-alih di browser Anda",
"nativeSpotify": "Gunakan aplikasi Spotify",
"playerbarWaveformStretch": "Peregangan bentuk gelombang",
"playerbarWaveformStretch_description": "Meregangkan bentuk gelombang untuk memenuhi ruang yang tersedia",
"preventSuspendOnPlayback_description": "Cegah aplikasi ditangguhkan saat musik diputar",
"preventSuspendOnPlayback": "Cegah penangguhan saat pemutaran",
"sidebarPlaylistFolders_description": "Buat tampilan folder untuk daftar putar yang menyertakan pemisah yang dikonfigurasi dalam namanya",
"sidebarPlaylistFolders": "Aktifkan folder",
"sidebarPlaylistFolderSeparator_description": "Karakter (atau string) yang memisahkan tingkat folder dalam nama daftar putar",
"sidebarPlaylistFolderSeparator": "Pemisah folder",
"sidebarPlaylistFolderView_description": "Cara folder ditampilkan di bilah sisi",
"sidebarPlaylistFolderView": "Tampilan folder",
"sidebarPlaylistFolderView_optionSingle": "Folder tunggal",
"sidebarPlaylistFolderView_optionTree": "Tampilan pohon",
"sidebarPlaylistFolderView_optionNavigation": "Tampilan navigasi",
"sidebarPlaylistFolderTreeIndent_description": "Jumlah piksel indentasi tiap tingkat pohon",
"sidebarPlaylistFolderTreeIndent": "Indentasi pohon",
"sidebarPlaylistFolderTreeLineColor_description": "Warna garis penghubung pohon (biarkan kosong untuk default tema)",
"sidebarPlaylistFolderTreeLineColor": "Warna garis pohon",
"sidebarPlaylistMode_description": "Cara setiap daftar putar ditampilkan dalam daftar bilah sisi",
"sidebarPlaylistMode": "Mode daftar putar bilah sisi",
"sidebarPlaylistMode_optionCompact": "Ringkas",
"sidebarPlaylistMode_optionExpanded": "Diperluas",
"sidePlayQueueLayout": "Tata letak antrean putar samping",
"sidePlayQueueLayout_description": "Mengatur tata letak antrean putar samping yang terlampir",
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertikal",
"waveformLoadingDelay": "Penundaan pemuatan bentuk gelombang",
"waveformLoadingDelay_description": "Penundaan dalam detik sebelum memuat bentuk gelombang. Tingkatkan nilai ini jika Anda mengalami tersendat saat menggunakan pemutar web."
},
"table": {
"column": {
"album": "Album",
"albumArtist": "Artis album",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"albumCount": "Album",
"artist": "Artis",
"biography": "Biografi",
"bitrate": "Bitrate",
"bpm": "Lpm",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"channels": "Saluran",
"codec": "Kodek",
"comment": "Komentar",
"dateAdded": "Tanggal ditambahkan",
"discNumber": "Nomor disk",
"favorite": "Favorit",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "Genre",
"lastPlayed": "Terakhir diputar",
"path": "Jalur",
"playCount": "Putaran",
"rating": "Penilaian",
"releaseDate": "Tanggal rilis",
"releaseYear": "Tahun",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"size": "Ukuran",
"songCount": "Trek",
"title": "Judul",
"trackNumber": "Pista",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"bitDepth": "Kedalaman Bit",
"sampleRate": "Laju Sampel",
"owner": "Pemilik"
},
"config": {
@@ -1307,6 +1363,13 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioConsentAllow": "Izinkan",
"systemAudioConsentBody": "Visualizer memerlukan akses ke audio sistem agar dapat berfungsi",
"systemAudioConsentDecline": "Tolak",
"systemAudioConsentTitle": "Izinkan akses ke audio sistem?",
"systemAudioCaptureFailed": "Tidak dapat memulai pengambilan: {{message}}",
"systemAudioNoAudioTrack": "Tidak ada trek audio yang dikembalikan. Pastikan pengambilan audio diaktifkan saat diminta.",
"systemAudioExclusiveModeNotSupported": "Visualizer tidak tersedia saat mode audio eksklusif diaktifkan. Nonaktifkan Mode Audio Eksklusif di pengaturan MPV lalu coba lagi."
}
}
-1
View File
@@ -438,7 +438,6 @@
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} con {{lastfm}} fallback",
"autoDJ": "Auto DJ",
"autoDJ_description": "Aggiungi automaticamente canzoni simili alla coda",
"autoDJ_itemCount": "Conteggio elementi",
"analyticsDisable_description": "Alcuni dati anonimi sull'utilizzo vengono inviati allo sviluppatore per migliorare l'applicazione",
"artistBackground": "Immagine dello sfondo dell'artista",
+19 -15
View File
@@ -48,7 +48,8 @@
"holdToShuffle": "長押しでシャッフル",
"albumRadio": "アルバム・ラジオ",
"artistRadio": "アーティストラジオ",
"trackRadio": "ラジオを追跡する"
"trackRadio": "ラジオを追跡する",
"scrobbleForceSubmit": "強制 Scrobble"
},
"setting": {
"crossfadeStyle_description": "オーディオプレーヤーが使用するクロスフェードのスタイルを選択します",
@@ -314,7 +315,6 @@
"exportImportSettings_importSuccess": "設定が正常にインポートされました!",
"exportImportSettings_importModalTitle": "Feishin 設定をインポート",
"exportImportSettings_importBtn": "設定をインポート",
"autoDJ_description": "類似の曲を自動でキューに追加します",
"autoDJ": "自動 DJ",
"autoDJ_itemCount_description": "自動 DJ が有効なときにキューに追加しようとした曲数",
"autoDJ_itemCount": "曲数",
@@ -429,7 +429,8 @@
"playerbarWaveformStretch": "波形伸縮",
"playerbarWaveformStretch_description": "波形を伸縮させて、利用可能なスペースを埋めます",
"preventSuspendOnPlayback_description": "音楽再生中にアプリケーションが停止しないようにします",
"preventSuspendOnPlayback": "再生の中断を防止する"
"preventSuspendOnPlayback": "再生の中断を防止する",
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示"
},
"action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
@@ -473,7 +474,9 @@
"openApplicationDirectory": "アプリケーションディレクトリを開く",
"selectRangeOfItems": "項目の範囲を選択",
"addOrRemoveFromSelection": "選択に追加または削除",
"goToCurrent": "現在の項目へ移動"
"goToCurrent": "現在の項目へ移動",
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
"expandAllFolders": "すべてのフォルダーを展開する"
},
"common": {
"backward": "戻る",
@@ -594,7 +597,8 @@
"rename": "名前を変更",
"newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化"
"grouping": "グループ化",
"back": "戻る"
},
"table": {
"config": {
@@ -643,7 +647,7 @@
"titleCombined": "$t(common.title) (結合)",
"dateAdded": "追加日",
"size": "$t(common.size)",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"lastPlayed": "最後に再生",
"trackNumber": "トラック番号",
"rowIndex": "行インデックス",
@@ -681,7 +685,7 @@
"rating": "評価",
"favorite": "お気に入り",
"playCount": "再生回数",
"albumCount": "$t(entity.album, {\"count\": 2})",
"albumCount": "アルバム",
"releaseYear": "年",
"lastPlayed": "最後に再生",
"biography": "バイオグラフィー",
@@ -690,18 +694,18 @@
"title": "タイトル",
"bpm": "BPM",
"dateAdded": "追加日",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
"artist": "アーティスト",
"songCount": "トラック",
"trackNumber": "トラック",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "ジャンル",
"albumArtist": "アルバムアーティスト",
"path": "パス",
"discNumber": "ディスク",
"channels": "$t(common.channel, {\"count\": 2})",
"size": "$t(common.size)",
"codec": "$t(common.codec)",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)",
"channels": "チャンネル",
"size": "サイズ",
"codec": "コーデック",
"bitDepth": "ビット深度",
"sampleRate": "サンプリング周波数",
"owner": "所有者"
}
},
-1
View File
@@ -653,7 +653,6 @@
"globalMediaHotkeys_description": "Het gebruik van systeem mediahotkeys voor controle van afspelen aan-/uitzetten",
"globalMediaHotkeys": "Globale mediasneltoetsen",
"autoDJ": "Auto-DJ",
"autoDJ_description": "Soortgelijke nummers automatisch aan wachtrij toevoegen",
"autoDJ_itemCount": "Aantal items",
"autoDJ_itemCount_description": "Het aantal items dat aan de wachtrij wordt geprobeerd toe te voegen als auto-DJ is ingeschakeld",
"autoDJ_timing": "Timing",
+21 -7
View File
@@ -173,7 +173,8 @@
"newVersionAvailable": "Nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "Grupowanie",
"back": "Wstecz"
"back": "Wstecz",
"openFolder": "Otwórz folder"
},
"entity": {
"genre_one": "Gatunek",
@@ -409,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"
@@ -883,7 +889,7 @@
"customCssEnable": "Włącz niestandardowy CSS",
"customCssEnable_description": "Pozwalaj na pisanie niestandardowego CSS",
"customCssNotice": "Ostrzeżenie: chociaż istnieje pewne filtrowanie (uniemożliwia używanie URL() i content:), używanie niestandardowego CSS-a może stwarzać ryzyko przez zmiany w interfejsie",
"customCss_description": "Zawartość niestandardowego CSS. Uwaga: content i zdalne URL są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazana poniżej. Dodatkowe pola których nie ustawiłeś, są obecne z powodu sanityzacji",
"customCss_description": "Zawartość niestandardowego CSS. Uwaga: content i zdalne URL są niedozwolonymi właściwościami. Podgląd twojej zawartości jest pokazany poniżej. Dodatkowe pola których nie ustawiłeś są obecne z powodu sanityzacji. Aplikacja komputerowa: feishin odczytuje i zapisuje custom.css w katalogu ustawień aplikacji i przeładowuje go gdy plik się zmieni",
"customCss": "Niestandardowy css",
"trayEnabled_description": "Pokaż/ukryj ikonę/menu w zasobniku. jeżeli wyłączone, wyłącza też minimalizowanie.wyjście do zasobnika",
"webAudio_description": "Używaj web audio. Włącza to zaawansowane funkcje takie jak ReplayGain. Wyłącz jeżeli nie działa poprawnie",
@@ -973,7 +979,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ń",
@@ -989,9 +995,8 @@
"audioFadeOnStatusChange": "Przenikanie dźwięku przy zmianie statusu",
"audioFadeOnStatusChange_description": "Umożliwia zanikanie lub pojawianie się dźwięku gdy zmieni się status play/pauza",
"autoDJ": "Automatyczny DJ",
"autoDJ_description": "Automatycznie dodawaj podobne piosenki do kolejki",
"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 +1098,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": {
+348 -17
View File
@@ -20,8 +20,25 @@
"viewPlaylists": "Ver $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "Abrir em Last.fm",
"musicbrainz": "Abrir em MusicBrainz"
}
"musicbrainz": "Abrir em MusicBrainz",
"listenbrainz": "Abrir no ListenBrainz",
"qobuz": "Abrir no Qobuz",
"spotify": "Abrir no Spotify"
},
"addOrRemoveFromSelection": "Adicionar ou remover da seleção",
"goToCurrent": "Ir para o elemento atual",
"collapseAllFolders": "Colapsar todas as pastas",
"expandAllFolders": "Expandir todas as pastas",
"createRadioStation": "Criar $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "Apagar $t(entity.radioStation, {\"count\": 1})",
"selectAll": "Selecionar tudo",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"holdToMoveToTop": "Segure para ir ao topo",
"holdToMoveToBottom": "Mover para ir ao fundo",
"moveItems": "Mover elementos",
"viewMore": "Ver mais",
"openApplicationDirectory": "Abrir a pasta da aplicação"
},
"common": {
"action_one": "Ação",
@@ -122,7 +139,21 @@
"unknown": "Desconhecido",
"version": "Versão",
"year": "Ano",
"yes": "Sim"
"yes": "Sim",
"countSelected": "{{count}} selecionado",
"bitDepth": "Profundidade de bits",
"example": "Exemplo",
"externalLinks": "Ligações externas",
"mood": "Humor",
"private": "Privado",
"public": "Público",
"retry": "Tentar novamente",
"rename": "Renomear",
"sampleRate": "Taxa de amostragem",
"sort": "Ordenar",
"clean": "Limpar",
"itemsMore": "{{count}} mais",
"newVersionAvailable": "Uma nova versão está disponível"
},
"entity": {
"album_one": "Álbum",
@@ -201,7 +232,9 @@
"serverNotSelectedError": "Nenhum servidor selecionado",
"serverRequired": "Servidor necessário",
"sessionExpiredError": "A sua sessão expirou",
"systemFontError": "Ocorreu um erro ao tentar obter fontes do sistema"
"systemFontError": "Ocorreu um erro ao tentar obter fontes do sistema",
"invalidJson": "JSON inválido",
"noNetwork": "Servidor não disponível"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -245,7 +278,10 @@
"songCount": "Contador de músicas",
"title": "Titulo",
"toYear": "Até o ano",
"trackNumber": "Faixa"
"trackNumber": "Faixa",
"matchAnd": "E",
"matchOr": "Ou",
"sortName": "Ordenar por nome"
},
"form": {
"addServer": {
@@ -259,7 +295,8 @@
"input_url": "Url",
"input_username": "Nome de utilizador",
"success": "Servidor adicionado com sucesso",
"title": "Adicionar servidor"
"title": "Adicionar servidor",
"input_remoteUrl": "URL público"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
@@ -292,7 +329,9 @@
},
"queryEditor": {
"input_optionMatchAll": "Corresponder todos",
"input_optionMatchAny": "Corresponder qualquer um"
"input_optionMatchAny": "Corresponder qualquer um",
"resetToDefault": "Restaurar à predefinição",
"clearFilters": "Limpar filtros"
},
"shareItem": {
"allowDownloading": "Permitir descargas",
@@ -305,6 +344,21 @@
"updateServer": {
"success": "Servidor atualizado com sucesso",
"title": "Atualizar servidor"
},
"createRadioStation": {
"title": "Criar estação de rádio",
"input_name": "Nome"
},
"lyricsExport": {
"input_synced": "Exportar letras sincronizadas"
},
"shuffleAll": {
"title": "Tocar aleatório",
"input_minYear": "A partir do ano",
"input_maxYear": "Até o ano"
},
"privateMode": {
"title": "Modo Privado"
}
},
"page": {
@@ -317,7 +371,9 @@
"topSongs": "Músicas mais tocadas",
"topSongsFrom": "Músicas mais tocadas de {{title}}",
"viewAll": "Ver tudo",
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})"
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})",
"topSongsCommunity": "Comunidade",
"topSongsPersonal": "Pessoal"
},
"albumArtistList": {
"title": "$t(entity.albumArtist, {\"count\": 2})"
@@ -374,7 +430,8 @@
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "Partilhar elemento",
"showDetails": "Obter informações"
"showDetails": "Obter informações",
"goTo": "Ir para"
},
"fullscreenPlayer": {
"config": {
@@ -417,7 +474,8 @@
"mostPlayed": "Mais tocado",
"newlyAdded": "Lançamentos recém-adicionados",
"recentlyPlayed": "Tocado recentemente",
"title": "$t(common.home)"
"title": "$t(common.home)",
"genres": "$t(entity.genre, {\"count\": 2})"
},
"itemDetail": {
"copyPath": "Copiar caminho para a área de transferência",
@@ -435,7 +493,18 @@
"generalTab": "Geral",
"hotkeysTab": "Teclas de atalho",
"playbackTab": "Reprodução",
"windowTab": "Janela"
"windowTab": "Janela",
"application": "Aplicação",
"queryBuilder": "Construtor de Consultas",
"theme": "Tema",
"controls": "Controles",
"sidebar": "Barra lateral",
"remote": "Remoto",
"exportImport": "Importar/exportar",
"audio": "Áudio",
"lyrics": "Letras",
"transcoding": "Transcodificar",
"discord": "Discord"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
@@ -450,12 +519,19 @@
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"shared": "$t(entity.playlist, {\"count\": 2}) partilhada",
"tracks": "$t(entity.track, {\"count\": 2})"
"tracks": "$t(entity.track, {\"count\": 2})",
"collections": "Coleções"
},
"trackList": {
"artistTracks": "Faixas de {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"radioList": {
"title": "Estações de rádio"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
}
},
"player": {
@@ -489,7 +565,11 @@
"toggleFullscreenPlayer": "Alternar player de ecrã cheio",
"unfavorite": "Remover favorito",
"pause": "Pausar",
"viewQueue": "Ver fila"
"viewQueue": "Ver fila",
"lyrics": "Letra",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_off": "Desligado"
},
"setting": {
"accentColor": "Cor de realce",
@@ -528,18 +608,269 @@
"discordApplicationId": "{{discord}} ID da aplicação",
"discordIdleStatus_description": "Quando ativado, atualiza o estado enquanto o player está ocioso",
"discordUpdateInterval_description": "O tempo em segundos entre cada atualização (mínimo 15 segundos)",
"playButtonBehavior_description": "Define o comportamento padrão do botão play ao adicionar músicas à fila"
"playButtonBehavior_description": "Define o comportamento padrão do botão play ao adicionar músicas à fila",
"autoDJ_itemCount": "Número de elementos",
"autoDJ_timing": "Tempo",
"customCss_description": "Conteúdo CSS personalizado. Observação: conteúdo e urls remotas são propriedades não permitidas. Uma pré-visualização do seu conteúdo é exibida abaixo. Campos adicionais que não definiu estão presentes devido à sanitização",
"automaticUpdates": "Atualizações automáticas",
"releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Mais recente",
"discordApplicationId_description": "O ID da aplicação para o rich presence do {{discord}} (defaults: {{defaultId}})",
"discordDisplayType_artistname": "Nome(s) do(s) artista(s)",
"discordDisplayType": "Tipo de exibição da presença do {{discord}}",
"discordIdleStatus": "Mostrar estado ocioso do rich presence",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} com alternativa para {{lastfm}}",
"discordLinkType_none": "$t(common.none)",
"discordLinkType": "Ligações de presença do {{discord}}",
"discordServeImage_description": "Partilhar a capa para o rich presence do {{discord}} a partir do próprio servidor, disponível apenas para Jellyfin e Navidrome. O {{discord}} usa um bot para buscar imagens, portanto o seu servidor deve estar acessível pela internet pública",
"discordUpdateInterval": "Intervalo de atualização do rich presence do {{discord}}",
"exportImportSettings_control_importText": "Importar configurações",
"exportImportSettings_control_title": "Importar / exportar configurações",
"exportImportSettings_importBtn": "Importar configurações",
"exportImportSettings_importModalTitle": "Importar configurações do Feishin",
"externalLinks": "Mostrar ligações externas",
"font": "Fonte",
"fontType_optionBuiltIn": "Fonte embutida",
"fontType_optionCustom": "Fonte personalizada",
"fontType_optionSystem": "Fonte do sistema",
"fontType": "Tipo da fonte",
"homeFeatureStyle_optionMultiple": "Múltiplos",
"hotkey_globalSearch": "Pequisa global",
"hotkey_playbackPause": "Pausar",
"hotkey_playbackPlay": "Tocar",
"hotkey_playbackPlayPause": "Play / pausar",
"hotkey_playbackStop": "Parar",
"hotkey_volumeMute": "Volume mudo",
"hotkey_volumeUp": "Aumentar volume",
"hotkey_zoomIn": "Aproximar",
"hotkey_zoomOut": "Afastar",
"imageAspectRatio_description": "Se ativado, a capa será exibida usando a sua proporção nativa. Para capas que não forem 1:1, o espaço restante ficará vazio",
"imageAspectRatio": "Usar proporção nativa da capa",
"language_description": "Define o idioma da aplicação ($t(common.restartRequired))",
"lastfm_description": "Exibir ligações para o Last.fm nas páginas de artista/álbum",
"lastfm": "Mostrar ligações do Last.fm",
"lastfmApiKey_description": "A chave de API para {{lastfm}}. Necessária para capas de álbuns",
"lastfmApiKey": "{{lastfm}} chave API",
"lyricFetch_description": "Buscar letras em várias fontes da internet",
"lyricFetch": "Buscar letras na internet",
"lyricFetchProvider": "Provedores para buscar letras",
"lyricOffset_description": "Compensar a letra pelo valor especificado em milissegundos",
"lyricOffset": "Compensação da letra (ms)",
"minimizeToTray_description": "Minimizar a aplicação para a bandeja do sistema",
"minimizeToTray": "Minimizar para a bandeja",
"minimumScrobblePercentage_description": "O percentual mínimo da música que deve ser reproduzido antes de ser scrobblada",
"minimumScrobblePercentage": "Duração mínima para scrobble (percentual)",
"minimumScrobbleSeconds_description": "A duração mínima em segundos da música que deve ser reproduzida antes de ser scrobblada",
"minimumScrobbleSeconds": "Scrobble mínimo (segundos)",
"mpvExecutablePath": "Caminho do executável do mpv",
"mpvExtraParameters_help": "Um por linha",
"musicbrainz_description": "Exibir ligações para o MusicBrainz nas páginas de artista/álbum, quando o ID do MusicBrainz existir",
"musicbrainz": "Mostrar ligações do MusicBrainz",
"neteaseTranslation_description": "Quando ativado, busca e exibe letras traduzidas do NetEase, se disponíveis",
"neteaseTranslation": "Ativar traduções do NetEase",
"passwordStore_description": "Qual armazenamento de palavras-passe/segredos usar. Altere isto se tem problemas para armazenar palavras-passe",
"playbackStyle_description": "Selecione o estilo de reprodução a ser usado pelo reprodutor de áudio",
"playbackStyle_optionCrossFade": "Transição suave",
"playbackStyle_optionNormal": "Normal",
"playbackStyle": "Estilo de reprodução",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior": "Comportamento do botão de reprodução",
"imageResolution_optionSidebar": "Barra lateral",
"imageResolution_optionHeader": "Cabeçalho",
"imageResolution_optionFullScreenPlayer": "Reprodutor de ecrã cheio",
"playerbarOpenDrawer_description": "Permite clicar na barra do reprodutor para abrir o reprodutor em ecrã cheio",
"playerbarOpenDrawer": "Alternar ecrã cheio na barra do reprodutor",
"playerbarSliderType_optionWaveform": "Forma de onda",
"playerbarWaveformAlign_optionTop": "Topo",
"playerbarWaveformAlign_optionCenter": "Centro",
"playerbarWaveformAlign_optionBottom": "Fundo",
"showRatings_description": "Exibir ou ocultar as avaliações por estrelas",
"showRatings": "Exibir avaliações por estrelas",
"remotePassword_description": "Define a palavra-passe do servidor de controlo remoto. Estas credenciais, por padrão, são transferidas de forma insegura — use uma palavra-passe única da qualnão dependa",
"remotePort": "Porta do servidor de controlo remoto",
"replayGainClipping_description": "Evitar clipping causado pelo {{ReplayGain}} reduzindo automaticamente o ganho",
"replayGainClipping": "Clipping do {{ReplayGain}}",
"replayGainFallback": "Fallback do {{ReplayGain}}",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
"replayGainMode": "Modo {{ReplayGain}}",
"replayGainPreamp": "Pré-amplificador {{ReplayGain}} (db)",
"sampleRate": "Taxa de amostragem",
"sidebarPlaylistFolderView_optionNavigation": "Vista de navegação",
"sidebarPlaylistMode_optionCompact": "Compacto",
"sidebarPlaylistMode_optionExpanded": "Expandido",
"sidePlayQueueStyle_optionAttached": "Anexado",
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"startMinimized": "Abrir minimizado",
"theme": "Tema",
"themeDark": "Tema (escuro)",
"transcode": "Ativar transcodificação",
"translationTargetLanguage": "Idioma de destino para tradução",
"useSystemTheme": "Usar tema do sistema",
"queryBuilderCustomFields_inputLabel": "Etiqueta"
},
"table": {
"column": {
"discNumber": "Disco",
"size": "$t(common.size)",
"title": "Titulo"
"size": "Tamanho",
"title": "Titulo",
"album": "Álbum",
"albumArtist": "Artista do álbum",
"albumCount": "Álbuns",
"artist": "Artista",
"biography": "Bibliografia",
"bitDepth": "Profundidade de Bits",
"bitrate": "Bitrate",
"bpm": "Bpm",
"channels": "Canais",
"codec": "Codec",
"comment": "Comentário",
"dateAdded": "Data Adicionada",
"favorite": "Favorito",
"genre": "Gênero",
"lastPlayed": "Última tocada",
"path": "Caminho",
"playCount": "Reproduções",
"rating": "Avaliação",
"releaseDate": "Data de Lançamento",
"releaseYear": "Ano",
"sampleRate": "Taxa de amostragem",
"trackNumber": "Faixa",
"owner": "Dono"
},
"config": {
"label": {
"discNumber": "Numero do disco",
"titleCombined": "$t(common.title) (combinado)"
"titleCombined": "$t(common.title) (combinado)",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"codec": "$t(common.codec)",
"composer": "Compositor",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre, {\"count\": 1})",
"image": "Imagem",
"lastPlayed": "Última reprodução",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "Contador de Reprodução",
"rating": "$t(common.rating)",
"releaseDate": "Data de Lançamento",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"title": "$t(common.title)",
"year": "$t(common.year)"
},
"general": {
"advancedSettings": "Configurações avançadas",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"alignLeft": "Alinhar à esquerda",
"alignCenter": "Alinhar ao centro",
"alignRight": "Alinhar à direita",
"displayType": "Tipo do ecrã",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"size_default": "Predefinição",
"size_compact": "Compacto",
"size_large": "Grande",
"tableColumns": "Colunas das tabelas",
"pagination": "Paginação",
"pagination_itemsPerPage": "Elementos por página",
"pagination_infinite": "Infinito",
"pagination_paginate": "Paginado",
"showHeader": "Exibir cabeçalho"
},
"view": {
"grid": "Grade"
}
}
},
"filterOperator": {
"contains": "Contém",
"endsWith": "Termina com",
"is": "É",
"isNot": "Não é",
"isGreaterThan": "É maior que",
"isLessThan": "É menor que",
"notContains": "Não contém",
"startsWith": "Começa com"
},
"releaseType": {
"primary": {
"broadcast": "Broadcast",
"ep": "EP",
"other": "Outros",
"single": "Simples"
},
"secondary": {
"compilation": "Compilação",
"djMix": "Mixagem de DJ",
"demo": "Demo",
"interview": "Entrevista",
"live": "Ao Vivo"
}
},
"visualizer": {
"systemAudioConsentAllow": "Permitir",
"systemAudioConsentDecline": "Recusar",
"ignoredPresets": "Predefinições Ignoradas",
"selectedPresets": "84",
"presets": "Predefinições",
"applyPreset": "Aplicar Predefinição",
"saveAsPreset": "Gravar como Predefinição",
"updatePreset": "Atualizar Predefinição",
"copyConfiguration": "Copiar Configuração",
"pasteConfiguration": "Colar Configuração",
"presetNamePlaceholder": "Digite o nome da predefinição",
"general": "Geral",
"mode": "Modo",
"mode1To8": "Modo 1 - 8",
"mode10": "Modo 10",
"lineWidth": "Largura da Linha",
"opacity": "Opacidade",
"vertical": "Vertical",
"horizontal": "Horizontal",
"level": "Nível",
"remove": "Remover",
"custom": "Personalizado",
"builtIn": "Embutido",
"colors": "Cores",
"gradient": "Gradiente",
"smoothing": "Suavizamento",
"sensitivity": "Sensibilidade",
"gravity": "Gravidade",
"radial": "Radial",
"radius": "Raio",
"options": {
"colorMode": {
"gradient": "Gradiente"
},
"gradient": {
"classic": "Clássico",
"rainbow": "Arco-íris"
},
"frequencyScale": {
"none": "Nenhum"
},
"weightingFilter": {
"none": "Nenhum",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
+13 -7
View File
@@ -41,7 +41,9 @@
"viewMore": "Посмотреть больше",
"openApplicationDirectory": "Открыть папку приложения",
"selectRangeOfItems": "Выбрать диапазон элементов",
"goToCurrent": "Перейти к текущему элементу"
"goToCurrent": "Перейти к текущему элементу",
"collapseAllFolders": "Свернуть все папки",
"expandAllFolders": "Развернуть все папки"
},
"common": {
"backward": "Назад",
@@ -171,7 +173,8 @@
"externalLinks": "Внешние ссылки",
"explicitStatus": "Признак нецензурного контента",
"newVersionAvailable": "Доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов"
"numberOfResults": "{{numberOfResults}} результатов",
"back": "Назад"
},
"entity": {
"album_one": "Альбом",
@@ -430,7 +433,8 @@
"sleepTimer_timeRemaining": "{{time}} осталось",
"sleepTimer_setCustom": "Установить таймер",
"sleepTimer_custom": "Пользовательский",
"sleepTimer_cancel": "Отменить таймер"
"sleepTimer_cancel": "Отменить таймер",
"scrobbleForceSubmit": "Принудительная скробблинг"
},
"page": {
"sidebar": {
@@ -491,7 +495,8 @@
"selectMusicFolder": "Выбрать папку с музыкой",
"noMusicFolder": "Папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок",
"commandPalette": "открыть командную строку"
"commandPalette": "открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
},
"manageServers": {
"title": "Сервера",
@@ -569,7 +574,8 @@
},
"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})"
},
"trackList": {
"artistTracks": "Треки {{artist}}",
@@ -681,7 +687,8 @@
"input_skipDuplicates": "не добавлять дубликаты",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"noneAdded": "Ни один трек не добавлен в $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"updateServer": {
"title": "Обновление сервера",
@@ -950,7 +957,6 @@
"artistBackground_description": "Добавляет фоновое изображение для страниц исполнителя, содержащих обложку исполнителя",
"artistBackgroundBlur": "Процент размытия обложки исполнителя",
"artistBackgroundBlur_description": "Регулирует процент размытия к заднему фону исполнителя",
"autoDJ_description": "Автоматически добавлять похожие песни в очередь воспроизведения",
"autoDJ_itemCount": "Количество элементов",
"autoDJ_itemCount_description": "Количество элементов, которые пытаются добавить в очередь при включенной функции автоматического диджеинга",
"autoDJ_timing": "Расчетное время",
-1
View File
@@ -881,7 +881,6 @@
"preservePitch": "சுருதியைப் பாதுகாக்கவும்",
"preservePitch_description": "பின்னணி வேகத்தை மாற்றும்போது சுருதியைப் பாதுகாக்கிறது",
"autoDJ": "ஆட்டோ டி.சே",
"autoDJ_description": "தானாக வரிசையில் ஒத்த பாடல்களைச் சேர்க்கவும்",
"autoDJ_itemCount": "பொருள் எண்ணிக்கை",
"autoDJ_itemCount_description": "ஆட்டோ DJ இயக்கப்பட்டிருக்கும் போது, வரிசையில் சேர்க்க முயற்சிக்கும் உருப்படிகளின் எண்ணிக்கை",
"autoDJ_timing": "நேரவிவரம்",
+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": "Альбом",
-1
View File
@@ -508,7 +508,6 @@
"combinedLyricsAndVisualizer_description": "将歌词和可视化界面合并到同一面板中",
"queryBuilderCustomFields_description": "在查询构建器添加自定义字段",
"combinedLyricsAndVisualizer": "在播放器侧边栏合并歌词和可视化界面",
"autoDJ_description": "自动添加相似歌曲到队列中",
"notify_description": "歌曲变更时显示通知",
"mpvExtraParameters_description": "向MPV传递额外参数",
"audioFadeOnStatusChange": "音频改变时淡入淡出",
+25 -10
View File
@@ -119,7 +119,8 @@
"newVersionAvailable": "有新的版本可供使用",
"numberOfResults": "{{numberOfResults}} 項結果",
"grouping": "分組",
"back": "返回"
"back": "返回",
"openFolder": "開啟資料夾"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -419,7 +420,8 @@
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器",
"albumRadio": "專輯電台",
"scrobbleForceSubmit": "強制紀錄"
"scrobbleForceSubmit": "強制紀錄",
"sleepTimer_endOfAlbum": "專輯播完時"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -594,7 +596,7 @@
"customCssEnable_description": "允許撰寫自訂CSS",
"customCssNotice": "警告:即使已限制某些用法(不允許 URL() 和 content:),但使用自訂 CSS 仍然會透過更改介面帶來風險",
"customCss": "自訂CSS",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位",
"customCss_description": "自訂 CSS 內容。注意:內容和遠端 URL 是不允許使用的屬性。您的內容預覽如下所示。由於需要進行清理,因此存在一些您未設定的其他欄位。桌面端:feishin在應用程式配置目錄中讀取和寫入custom.css,並在檔案更改時重新載入",
"discordPausedStatus": "暫停時顯示 Rich Presence",
"discordPausedStatus_description": "啟用後,播放器暫停時將顯示狀態",
"discordListening": "將狀態設為\"正在聽\"",
@@ -665,7 +667,7 @@
"preventSleepOnPlayback": "防止播放時進入睡眠狀態",
"preventSleepOnPlayback_description": "在音樂播放時防止螢幕進入睡眠狀態",
"mediaSession": "啟用 Media Session",
"mediaSession_description": "啟用 Media Session 整合功能,系統音量 Overlay 和鎖定畫面顯示媒體資料與控制面板",
"mediaSession_description": "啟用 Media Session 整合功能,系統音量疊加層和鎖定畫面顯示媒體控制項與中繼資料。此功能需要使用網頁播放器。",
"releaseChannel": "發佈通道",
"analyticsDisable": "選擇退出使用情況分析",
"analyticsDisable_description": "經過匿名處理的使用情況資料將傳送給開發者,以協助改進應用程式",
@@ -714,9 +716,8 @@
"playerFilters": "從佇列中過濾歌曲",
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
"autoDJ": "Auto DJ",
"autoDJ_description": "自動將相似的歌曲加入到播放佇列",
"autoDJ_itemCount": "歌曲數量",
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
"autoDJ_itemCount": "項目數量",
"autoDJ_itemCount_description": "嘗試加入佇列的項目數量",
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
"autoDJ_timing": "觸發時機",
"logLevel": "Log等級",
@@ -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": {
@@ -1137,7 +1147,12 @@
"input_played": "播放過濾器",
"input_played_optionAll": "所有曲目",
"input_played_optionUnplayed": "僅未播放的曲目",
"input_played_optionPlayed": "僅播放過的曲目"
"input_played_optionPlayed": "僅播放過的曲目",
"input_kind_albums": "專輯",
"input_kind_songs": "歌曲",
"input_kind": "隨機選取",
"input_limit_albums": "專輯數量?",
"input_limit_songs": "歌曲數量?"
},
"createRadioStation": {
"success": "電台建立成功",
@@ -1174,7 +1189,7 @@
"fieldRecording": "現場錄音",
"demo": "Demo",
"interview": "訪談",
"live": "Live",
"live": "現場演出",
"mixtape": "混音帶",
"remix": "Remix",
"soundtrack": "原聲帶",
+15
View File
@@ -0,0 +1,15 @@
export const disableAutoUpdates = () => {
return process.env['DISABLE_AUTO_UPDATES'];
};
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
};
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './visualizer';
+9 -2
View File
@@ -7,9 +7,10 @@ import { pid } from 'node:process';
import process from 'process';
import { getMainWindow, sendToastToRenderer } from '../../../index';
import { createLog, isMacOS, isWindows } from '../../../utils';
import { createLog } from '../../../utils';
import { store } from '../settings';
import { isMacOS, isWindows } from '/@/main/env';
import { PlayerData } from '/@/shared/types/domain-types';
declare module 'node-mpv';
@@ -119,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(
{
+1 -1
View File
@@ -1,8 +1,8 @@
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
import { isLinux, isMacOS } from '../../../utils';
import { store } from '../settings';
import { isLinux, isMacOS } from '/@/main/env';
import { PlayerType } from '/@/shared/types/types';
export const enableMediaKeys = (window: BrowserWindow | null) => {
+1 -1
View File
@@ -9,8 +9,8 @@ import { deflate, gzip } from 'zlib';
import manifest from './manifest.json';
import { isLinux } from '/@/main/env';
import { getMainWindow } from '/@/main/index';
import { isLinux } from '/@/main/utils';
import { QueueSong } from '/@/shared/types/domain-types';
import { ClientEvent, ServerEvent } from '/@/shared/types/remote-types';
import { PlayerRepeat, PlayerStatus, SongState } from '/@/shared/types/types';
+120 -1
View File
@@ -1,7 +1,18 @@
import type { TitleTheme } from '/@/shared/types/types';
import type { FSWatcher } from 'fs';
import { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
OpenDialogOptions,
safeStorage,
shell,
} from 'electron';
import Store from 'electron-store';
import { promises as fs, watch as fsWatch } from 'fs';
import path from 'path';
const getFrame = () => {
@@ -26,6 +37,67 @@ const storePath = isDevelopment
? path.normalize(`${defaultUserDataPath}-dev`)
: path.normalize(defaultUserDataPath);
const CUSTOM_CSS_FILENAME = 'custom.css';
const customCssPath = path.join(storePath, CUSTOM_CSS_FILENAME);
let customCssWatcher: FSWatcher | null = null;
let customCssDebounce: NodeJS.Timeout | null = null;
const readCustomCss = async (): Promise<{ content: string; exists: boolean }> => {
try {
const content = await fs.readFile(customCssPath, 'utf8');
return { content, exists: true };
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') {
return { content: '', exists: false };
}
console.error('Failed to read custom css file', error);
return { content: '', exists: false };
}
};
const notifyCustomCssUpdate = async () => {
const { content, exists } = await readCustomCss();
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send('custom-css-updated', {
content,
exists,
path: customCssPath,
});
});
};
const scheduleCustomCssUpdate = () => {
if (customCssDebounce) {
clearTimeout(customCssDebounce);
}
customCssDebounce = setTimeout(() => {
notifyCustomCssUpdate().catch((error) => {
console.error('Failed to broadcast custom css update', error);
});
}, 100);
};
const startCustomCssWatcher = async () => {
if (customCssWatcher) return;
try {
await fs.mkdir(storePath, { recursive: true });
customCssWatcher = fsWatch(storePath, (eventType, filename) => {
if (!filename) return;
if (filename.toString() !== CUSTOM_CSS_FILENAME) return;
if (eventType === 'change' || eventType === 'rename') {
scheduleCustomCssUpdate();
}
});
} catch (error) {
console.error('Failed to watch custom css file', error);
}
};
export const store = new Store<any>({
beforeEachMigration: (_store, context) => {
console.log(`settings migrate from ${context.fromVersion}${context.toVersion}`);
@@ -69,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;
@@ -120,3 +200,42 @@ ipcMain.handle('open-file-selector', async (_event, options: OpenDialogOptions)
return result.filePaths[0] || null;
});
ipcMain.handle('custom-css-get', async () => {
const { content, exists } = await readCustomCss();
return {
content,
exists,
path: customCssPath,
};
});
ipcMain.handle('custom-css-save', async (_event, data: { content: string }) => {
const content = typeof data?.content === 'string' ? data.content : '';
await fs.mkdir(storePath, { recursive: true });
await fs.writeFile(customCssPath, content, 'utf8');
await notifyCustomCssUpdate();
return true;
});
ipcMain.handle('custom-css-open-folder', async () => {
await fs.mkdir(storePath, { recursive: true });
await shell.openPath(storePath);
return true;
});
app.whenReady()
.then(() => startCustomCssWatcher())
.catch((error) => console.error('Failed to start custom css watcher', error));
app.on('before-quit', () => {
if (customCssWatcher) {
customCssWatcher.close();
customCssWatcher = null;
}
if (customCssDebounce) {
clearTimeout(customCssDebounce);
customCssDebounce = null;
}
});
@@ -0,0 +1,30 @@
import { ipcMain } from 'electron';
import { getMpvInstance } from '../player';
import { store } from '../settings';
import { PlayerType } from '/@/shared/types/types';
let isLocalVisualizerSurfaceVisible = false;
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
isLocalVisualizerSurfaceVisible = visible;
};
export const canHandleVisualizerDisplayMedia = (): boolean => {
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
if (playbackType !== PlayerType.LOCAL) {
return false;
}
if (!isLocalVisualizerSurfaceVisible) {
return false;
}
return Boolean(getMpvInstance()?.isRunning());
};
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
setLocalVisualizerSurfaceVisible(Boolean(visible));
});
+108 -25
View File
@@ -16,6 +16,7 @@ import {
protocol,
Rectangle,
screen,
session,
shell,
Tray,
} from 'electron';
@@ -30,18 +31,12 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
import MenuBuilder, { MenuPlaybackState } from './menu';
import {
autoUpdaterLogInterface,
createLog,
disableAutoUpdates,
hotkeyToElectronAccelerator,
isLinux,
isMacOS,
isWindows,
} from './utils';
import './features';
import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from './utils';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: {
@@ -257,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);
@@ -285,6 +282,16 @@ let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
let inputFocused = false;
ipcMain.on('input-focus-state', (_event, focused: boolean) => {
const next = !!focused;
if (inputFocused === next) return;
inputFocused = next;
if (isMacOS()) {
rebuildMainMenu();
}
});
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
@@ -330,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 => {
@@ -345,7 +352,7 @@ const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: playbackMenuAccelerators,
accelerators: inputFocused ? {} : playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
@@ -476,6 +483,15 @@ const createTray = () => {
tray.setContextMenu(contextMenu);
};
const validateUrl = (url: string): boolean => {
// Minor security, really. Enforce only loading websites (http/https). file://
// URLs and the like should've already been blocked, but this is another check.
// Note that arbitrary web URLs are still allowed under this scheme, although
// that should really only be hit by Subsonic share url (or if artist homepage
// is allowed for ND extensions)
return url.startsWith('http://') || url.startsWith('https://');
};
async function createWindow(first = true): Promise<void> {
if (isDevelopment) {
await installExtensions().catch(console.log);
@@ -515,9 +531,9 @@ async function createWindow(first = true): Promise<void> {
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
nodeIntegration: true,
nodeIntegration: false,
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
sandbox: true,
webSecurity: !store.get('ignore_cors'),
},
width: 1440,
@@ -729,11 +745,18 @@ async function createWindow(first = true): Promise<void> {
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
if (validateUrl(edata.url)) {
shell.openExternal(edata.url);
}
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!canHandleVisualizerDisplayMedia()) {
callback({});
return;
}
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
@@ -764,7 +787,9 @@ async function createWindow(first = true): Promise<void> {
nativeTheme.themeSource = theme || 'dark';
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
if (validateUrl(details.url)) {
shell.openExternal(details.url);
}
return { action: 'deny' };
});
@@ -966,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();
@@ -994,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, {
@@ -1008,7 +1049,49 @@ 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) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"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';",
],
},
});
});
createWindow();
-16
View File
@@ -18,22 +18,6 @@ if (process.env.NODE_ENV === 'development') {
};
}
export const disableAutoUpdates = () => {
return process.env['DISABLE_AUTO_UPDATES'];
};
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
};
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
-3
View File
@@ -1,11 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { PreloadApi } from './index';
declare global {
interface Window {
api: PreloadApi;
electron: ElectronAPI;
LEGACY_AUTHENTICATION?: boolean;
queryLocalFonts?: () => Promise<Font[]>;
REMOTE_URL?: string;
+4 -5
View File
@@ -1,5 +1,4 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote';
import { utils } from './utils';
import { visualizer } from './visualizer';
// Custom APIs for renderer
const api = {
autodiscover,
browser,
discordRpc,
getPathForFile: webUtils.getPathForFile,
ipc,
localSettings,
lyrics,
@@ -25,6 +26,7 @@ const api = {
mpvPlayerListener,
remote,
utils,
visualizer,
};
export type PreloadApi = typeof api;
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.api = api;
}
-10
View File
@@ -8,21 +8,11 @@ const send = (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
};
const invoke = (channel: string, ...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
};
const on = (channel: string, listener: (event: any, ...args: any[]) => void) => {
ipcRenderer.on(channel, listener);
};
const removeListener = (channel: string, listener: (event: any, ...args: any[]) => void) => {
ipcRenderer.removeListener(channel, listener);
};
export const ipc = {
invoke,
on,
removeAllListeners,
removeListener,
send,
+11 -3
View File
@@ -1,4 +1,4 @@
import { ipcRenderer, IpcRendererEvent, OpenDialogOptions, webFrame } from 'electron';
import { ipcRenderer, OpenDialogOptions, webFrame } from 'electron';
import { TitleTheme } from '/@/shared/types/types';
@@ -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 });
};
@@ -41,8 +48,8 @@ const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
};
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
ipcRenderer.on('custom-font-error', cb);
const fontError = (cb: (file: string) => void) => {
ipcRenderer.on('custom-font-error', (_, file) => cb(file));
};
const themeSet = (theme: TitleTheme): void => {
@@ -99,6 +106,7 @@ export const localSettings = {
passwordSet,
restart,
set,
setSync,
setZoomFactor,
themeSet,
};
+11 -15
View File
@@ -1,4 +1,4 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import { QueueSong } from '/@/shared/types/domain-types';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
@@ -31,28 +31,24 @@ const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
ipcRenderer.send('update-song', song, imageUrl);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', cb);
const requestSeek = (cb: (data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', (_, data) => cb(data));
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('request-position', cb);
const requestPosition = (cb: (data: { position: number }) => void) => {
ipcRenderer.on('request-position', (_, data) => cb(data));
};
const requestToggleRepeat = (
cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb);
const requestToggleRepeat = (cb: (data: { repeat: PlayerRepeat }) => void) => {
ipcRenderer.on('mpris-request-toggle-repeat', (_, data) => cb(data));
};
const requestToggleShuffle = (
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
const requestToggleShuffle = (cb: (data: { shuffle: boolean }) => void) => {
ipcRenderer.on('mpris-request-toggle-shuffle', (_, data) => cb(data));
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
const requestVolume = (cb: (data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', (_, data) => cb(data));
};
export const mpris = {
+37 -37
View File
@@ -1,4 +1,4 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import { PlayerData } from '/@/shared/types/domain-types';
@@ -102,76 +102,76 @@ const getAudioDevices = async () => {
return ipcRenderer.invoke('player-get-audio-devices');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
const rendererAutoNext = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', (_, data) => cb(data));
};
const rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {
ipcRenderer.on('renderer-player-current-time', cb);
const rendererCurrentTime = (cb: (data: number) => void) => {
ipcRenderer.on('renderer-player-current-time', (_, data) => cb(data));
};
const rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-next', cb);
const rendererNext = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-next', (_, data) => cb(data));
};
const rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-pause', cb);
const rendererPause = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-pause', (_, data) => cb(data));
};
const rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play', cb);
const rendererPlay = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play', (_, data) => cb(data));
};
const rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play-pause', cb);
const rendererPlayPause = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play-pause', (_, data) => cb(data));
};
const rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-previous', cb);
const rendererPrevious = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-previous', (_, data) => cb(data));
};
const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-stop', cb);
const rendererStop = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-stop', (_, data) => cb(data));
};
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
const rendererSkipForward = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', (_, data) => cb(data));
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
const rendererSkipBackward = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', (_, data) => cb(data));
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
const rendererVolumeUp = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', (_, data) => cb(data));
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
const rendererVolumeDown = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', (_, data) => cb(data));
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
const rendererVolumeMute = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', (_, data) => cb(data));
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
const rendererToggleRepeat = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', (_, data) => cb(data));
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
const rendererToggleShuffle = (cb: (data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', (_, data) => cb(data));
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
const rendererQuit = (cb: () => void) => {
ipcRenderer.on('renderer-player-quit', () => cb());
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
const rendererError = (cb: (data: string) => void) => {
ipcRenderer.on('renderer-player-error', (_, data) => cb(data));
};
const rendererPlayerFallback = (cb: (event: IpcRendererEvent, data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', cb);
const rendererPlayerFallback = (cb: (data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data));
};
export const mpvPlayer = {
+11 -16
View File
@@ -1,33 +1,28 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import { QueueSong } from '/@/shared/types/domain-types';
import { PlayerStatus } from '/@/shared/types/types';
const requestFavorite = (
cb: (
event: IpcRendererEvent,
data: { favorite: boolean; id: string; serverId: string },
) => void,
cb: (data: { favorite: boolean; id: string; serverId: string }) => void,
) => {
ipcRenderer.on('request-favorite', cb);
ipcRenderer.on('request-favorite', (_, data) => cb(data));
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('request-position', cb);
const requestPosition = (cb: (data: { position: number }) => void) => {
ipcRenderer.on('request-position', (_, data) => cb(data));
};
const requestRating = (
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
) => {
ipcRenderer.on('request-rating', cb);
const requestRating = (cb: (data: { id: string; rating: number; serverId: string }) => void) => {
ipcRenderer.on('request-rating', (_, data) => cb(data));
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', cb);
const requestSeek = (cb: (data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', (_, data) => cb(data));
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
const requestVolume = (cb: (data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', (_, data) => cb(data));
};
const setRemoteEnabled = (enabled: boolean): Promise<null | string> => {
+72 -33
View File
@@ -1,6 +1,6 @@
import { ipcRenderer, IpcRendererEvent, webFrame } from 'electron';
import { ipcRenderer, webFrame } from 'electron';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/utils';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
const openItem = async (path: string) => {
return ipcRenderer.invoke('open-item', path);
@@ -10,29 +10,44 @@ const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', cb);
const getCustomCss = async (): Promise<
| undefined
| {
content: string;
exists: boolean;
path?: string;
}
> => {
return ipcRenderer.invoke('custom-css-get');
};
const saveCustomCss = async (content: string) => {
return ipcRenderer.invoke('custom-css-save', { content });
};
const openCustomCssFolder = async () => {
return ipcRenderer.invoke('custom-css-open-folder');
};
const customCssUpdatedListener = (
cb: (data: { content?: string; exists?: boolean; path?: string }) => void,
) => {
const listener = (_event: unknown, data: { content?: string; exists?: boolean }) => cb(data);
ipcRenderer.on('custom-css-updated', listener);
return () => {
ipcRenderer.removeListener('custom-css-updated', listener);
};
};
const playerErrorListener = (cb: (data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
};
const mainMessageListener = (
cb: (
event: IpcRendererEvent,
data: { message: string; type: 'error' | 'info' | 'success' | 'warning' },
) => void,
cb: (data: { message: string; type: 'error' | 'info' | 'success' | 'warning' }) => void,
) => {
ipcRenderer.on('toast-from-main', cb);
};
const logger = (
cb: (
event: IpcRendererEvent,
data: {
message: string;
type: 'debug' | 'error' | 'info' | 'verbose' | 'warning';
},
) => void,
) => {
ipcRenderer.send('logger', cb);
ipcRenderer.on('toast-from-main', (_, data) => cb(data));
};
const download = (url: string) => {
@@ -43,6 +58,14 @@ const checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string
return ipcRenderer.invoke('app-check-for-updates');
};
const startPowerSaveBlocker = (full: boolean) => {
return ipcRenderer.invoke('power-save-blocker-start', { full });
};
const stopPowerSaveBlocker = () => {
return ipcRenderer.invoke('power-save-blocker-stop');
};
const forceGarbageCollection = (): boolean => {
try {
if (typeof global.gc === 'function') {
@@ -61,41 +84,51 @@ const forceGarbageCollection = (): boolean => {
}
};
const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb);
const setInputFocused = (focused: boolean) => {
ipcRenderer.send('input-focus-state', focused);
};
const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-command-palette', cb);
const rendererOpenSettings = (cb: () => void) => {
ipcRenderer.on('renderer-open-settings', () => cb());
};
const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-manage-servers', cb);
const rendererOpenCommandPalette = (cb: () => void) => {
ipcRenderer.on('renderer-open-command-palette', () => cb());
};
const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => {
const rendererOpenManageServers = (cb: () => void) => {
ipcRenderer.on('renderer-open-manage-servers', () => cb());
};
const rendererTogglePrivateMode = (cb: () => void) => {
ipcRenderer.on('renderer-toggle-private-mode', cb);
};
const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-sidebar', cb);
const rendererToggleSidebar = (cb: () => void) => {
ipcRenderer.on('renderer-toggle-sidebar', () => cb());
};
const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-release-notes', cb);
const rendererOpenReleaseNotes = (cb: () => void) => {
ipcRenderer.on('renderer-open-release-notes', () => cb());
};
const rendererUpdateAvailable = (cb: (version: string) => void) => {
ipcRenderer.on('update-available', (_, version) => cb(version));
};
export const utils = {
checkForUpdates,
customCssUpdatedListener,
disableAutoUpdates,
download,
forceGarbageCollection,
getCustomCss,
isLinux,
isMacOS,
isWindows,
logger,
mainMessageListener,
openApplicationDirectory,
openCustomCssFolder,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
@@ -104,6 +137,12 @@ export const utils = {
rendererOpenSettings,
rendererTogglePrivateMode,
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
separator: isWindows() ? '\\' : '/',
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
};
export type Utils = typeof utils;
+11
View File
@@ -0,0 +1,11 @@
import { ipcRenderer } from 'electron';
const setLocalSurfaceVisible = (visible: boolean) => {
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
};
export const visualizer = {
setLocalSurfaceVisible,
};
export type VisualizerApi = typeof visualizer;
+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,
@@ -1062,7 +1182,9 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get server info');
}
const defaultFeatures = {};
const defaultFeatures = {
[ServerFeature.REPORT_PLAYBACK]: [1],
};
const features = {
...defaultFeatures,
@@ -1841,6 +1963,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[]) {
@@ -289,6 +289,14 @@ export const contract = c.router({
200: ssType._response.removeFavorite,
},
},
reportPlayback: {
method: 'GET',
path: 'reportPlayback.view',
query: ssType._parameters.reportPlayback,
responses: {
200: ssType._response.reportPlayback,
},
},
savePlayQueue: {
method: 'GET',
path: 'savePlayQueue.view',
@@ -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,
},
});
@@ -1460,6 +1466,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.serverPlayQueue = [1];
}
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
features.reportPlayback = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
},
getSimilarSongs: async (args) => {
@@ -2298,6 +2308,56 @@ export const SubsonicController: InternalControllerEndpoint = {
scrobble: async (args) => {
const { apiClientProps, query } = args;
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
if (query.submission) {
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
return null;
}
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
switch (query.event) {
case 'pause':
state = 'paused';
break;
case 'start':
state = 'starting';
break;
case 'unpause':
state = 'playing';
break;
default:
state = 'playing';
}
const res = await ssApiClient(apiClientProps).reportPlayback({
query: {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
state,
},
});
if (res.status !== 200) {
throw new Error('Failed to report playback');
}
return null;
}
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
+113 -1
View File
@@ -15,7 +15,12 @@ import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
import {
useCssSettings,
useHotkeySettings,
useLanguage,
useSettingsStoreActions,
} from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { WebAudio } from '/@/shared/types/types';
@@ -31,6 +36,7 @@ const UpdateAvailableDialog = lazy(() =>
);
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
return <ThemedApp />;
@@ -89,10 +95,12 @@ const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CustomCssFileEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<NativeMenuSyncEffect />
<InputFocusEffect />
</>
);
@@ -141,6 +149,71 @@ const CssSettingsEffect = () => {
return null;
};
const CustomCssFileEffect = () => {
const { setSettings } = useSettingsStoreActions();
const { content } = useCssSettings();
const latestContentRef = useRef(content);
useEffect(() => {
latestContentRef.current = content;
}, [content]);
useEffect(() => {
if (!isElectron() || !utils) return;
let disposed = false;
const applyContent = (rawContent: string | undefined) => {
const sanitized = sanitizeCss(`<style>${rawContent ?? ''}`);
if (sanitized !== latestContentRef.current) {
setSettings({
css: {
content: sanitized,
},
});
}
};
const loadCustomCss = async () => {
try {
const result = await utils.getCustomCss();
if (disposed || !result) return;
if (!result.exists && latestContentRef.current) {
await utils.saveCustomCss(latestContentRef.current);
return;
}
applyContent(result.content);
} catch (error) {
console.error('Failed to load custom css', error);
}
};
const handleCustomCssUpdated = (data: { content?: string; exists?: boolean }) => {
if (disposed) return;
if (data?.exists === false) {
applyContent('');
return;
}
applyContent(data?.content);
};
const removeCustomCssUpdatedListener =
utils.customCssUpdatedListener(handleCustomCssUpdated);
loadCustomCss();
return () => {
disposed = true;
removeCustomCssUpdatedListener();
};
}, [setSettings]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
@@ -170,3 +243,42 @@ const NativeMenuSyncEffect = () => {
return null;
};
const InputFocusEffect = () => {
useEffect(() => {
if (!isElectron()) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as Element | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
window.api?.utils?.setInputFocused?.(true);
}
};
const handleFocusOut = (e: FocusEvent) => {
const related = e.relatedTarget as Element | null;
if (
related instanceof HTMLInputElement ||
related instanceof HTMLTextAreaElement ||
(related instanceof HTMLElement && related.isContentEditable)
) {
return;
}
window.api?.utils?.setInputFocused?.(false);
};
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
};
}, []);
return null;
};
@@ -27,6 +27,7 @@ import { PlayCountColumn } from './play-count-column';
import { RatingColumn } from './rating-column';
import { ReleaseDateColumn } from './release-date-column';
import { RowIndexColumn } from './row-index-column';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { SampleRateColumn } from './sample-rate-column';
import { SizeColumn } from './size-column';
import { TitleArtistColumn } from './title-artist-column';
@@ -111,6 +112,7 @@ export {
GenreBadgeColumn,
GenreColumn,
ImageColumn,
ItemDetailRowPlayControlCell,
LastPlayedColumn,
PathColumn,
PlayCountColumn,
@@ -1,23 +1,40 @@
import styles from './row-index-column.module.css';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { Icon } from '/@/shared/components/icon/icon';
import { PlayerStatus } from '/@/shared/types/types';
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
const status = usePlayerStatus();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
if (isActive) {
return (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
);
}
import { TableColumn } from '/@/shared/types/types';
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
return <>{String((rowIndex ?? 0) + 1)}</>;
};
const PlayableRowIndexColumn = (props: ItemDetailListCellProps) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
const indexContent = isActive ? (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
) : (
String((props.rowIndex ?? 0) + 1)
);
return (
<ItemDetailRowPlayControlCell
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
export const RowIndexColumn = (props: ItemDetailListCellProps) => {
if (!props.columns || !isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
return <DefaultRowIndexColumn {...props} />;
}
return <PlayableRowIndexColumn {...props} />;
};
@@ -0,0 +1,30 @@
.cell-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 100%;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import styles from './row-play-control-cell.module.css';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Play } from '/@/shared/types/types';
export const ItemDetailRowPlayControlCell = ({
indexContent,
onPlay,
showPlayControls,
}: {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
}) => {
if (!showPlayControls) {
return <>{indexContent}</>;
}
return (
<div className={styles.cellWrapper}>
<HoverCard
offset={{ crossAxis: 32, mainAxis: 16 }}
openDelay={300}
position="top"
withArrow
>
<HoverCard.Target>
<div className={styles.playTarget}>{indexContent}</div>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</div>
);
};
@@ -1,7 +1,46 @@
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import styles from './row-play-control-cell.module.css';
import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
const disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0');
return `${disc}-${track}`;
};
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
return <>{formatTrackNumber(song)}</>;
};
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
const indexContent = isActive ? (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
) : (
formatTrackNumber(props.song)
);
return (
<ItemDetailRowPlayControlCell
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
return <DefaultTrackNumberColumn {...props} />;
}
return <PlayableTrackNumberColumn {...props} />;
};
@@ -1,8 +1,9 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
columns?: ItemTableListColumnConfig[];
controls?: ItemControls;
internalState?: ItemListStateActions;
isMutatingFavorite?: boolean;
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { ItemDetailListCellProps } from './types';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { Song } from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const useDetailRowPlayControl = ({
internalState,
rowIndex = 0,
song,
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
const status = usePlayerStatus();
const player = usePlayer();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls = !!song?.id;
const handlePlay = useCallback(
(playType: Play) => {
if (!song) {
return;
}
playSongFromItemListControl({
index: rowIndex,
internalState,
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[internalState, player, rowIndex, song],
);
return {
handlePlay,
isActive,
isPlaying,
showPlayControls,
};
};
@@ -447,6 +447,14 @@
padding-left: 0;
}
.row .track-cell-play-control {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.track-row-dragging {
opacity: 0.5;
}
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
@@ -365,6 +366,7 @@ const TrackRow = memo(
const isTitleColumn = col.id === TableColumn.TITLE;
const isImageColumn = col.id === TableColumn.IMAGE;
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
const showHoverContent = shouldShowHoverOnlyColumnContent(
col.id,
isRowHovered,
@@ -374,6 +376,7 @@ const TrackRow = memo(
const content = isSongsLoading ? null : showHoverContent ? (
<CellComponent
columnId={col.id}
columns={columns}
controls={controls}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
@@ -393,6 +396,7 @@ const TrackRow = memo(
[styles.trackCellImage]: isImageColumn,
[styles.trackCellMuted]: !isTitleColumn,
[styles.trackCellNoHPadding]: isIconActionColumn,
[styles.trackCellPlayControl]: isPlayControlColumn,
[styles.trackCellVerticalBorderVisible]:
enableVerticalBorders && !isLastColumn,
[styles.trackCellWithVerticalBorder]: !isLastColumn,
@@ -88,7 +88,7 @@ export const RowPlayControlCell = (
<TableColumnContainer {...props} className={styles.expansionCell}>
<div className={styles.expansionInner}>
{showPlayControls ? (
<HoverCard openDelay={300} position="top" withArrow>
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>{expansionTarget}</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
@@ -110,7 +110,7 @@ export const RowPlayControlCell = (
return (
<TableColumnTextContainer {...props} className={styles.fullSizeContent}>
<HoverCard openDelay={300} position="top" withArrow>
<HoverCard openDelay={300} position="top" withArrow withinPortal={false}>
<HoverCard.Target>
<Flex className={styles.indexContent} justify="center" w="100%">
{getIndexDisplay(false)}
@@ -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>
@@ -5,23 +5,30 @@ import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';
import { useArtistRadioCount, useCurrentServerId, usePlayButtonBehavior } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface PlayTrackRadioActionProps {
disabled?: boolean;
skipFirstSong?: boolean;
song: Song;
}
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
export const PlayTrackRadioAction = ({
disabled,
skipFirstSong,
song,
}: PlayTrackRadioActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const playButtonBehavior = usePlayButtonBehavior();
const radioCount = useArtistRadioCount();
const handlePlayTrackRadio = useCallback(
async (playType: Play) => {
if (!serverId || !song) return;
@@ -30,6 +37,7 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: radioCount,
songId: song.id,
},
serverId,
@@ -38,13 +46,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
});
if (similarSongs && similarSongs.length > 0) {
player.addToQueueByData([song, ...similarSongs], playType);
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
const shouldSkipFirstSong =
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
player.addToQueueByData(queueSongs, playType);
}
} catch (error) {
console.error('Failed to load track radio:', error);
}
},
[player, queryClient, serverId, song],
[player, queryClient, radioCount, serverId, skipFirstSong, song],
);
const handlePlayTrackRadioNow = useCallback(() => {
@@ -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>
);
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
<MoveQueueItemsAction items={items} />
<ShuffleItemsAction items={items} />
<ContextMenu.Divider />
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
<ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
<ContextMenu.Divider />
@@ -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}
@@ -23,6 +23,8 @@ export interface WebPlayerEngineHandle extends AudioPlayer {
interface WebPlayerEngineProps {
isMuted: boolean;
isTransitioning: boolean;
loopPlayer1: boolean;
loopPlayer2: boolean;
onEndedPlayer1: () => void;
onEndedPlayer2: () => void;
onErrorPause: () => void;
@@ -55,6 +57,8 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
const {
isMuted,
isTransitioning,
loopPlayer1,
loopPlayer2,
onEndedPlayer1,
onEndedPlayer2,
onErrorPause,
@@ -292,8 +296,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false}
height={0}
id="web-player-1"
loop={loopPlayer1}
muted={isMuted}
onEnded={src1 ? () => onEndedPlayer1() : undefined}
onEnded={src1 && !loopPlayer1 ? () => onEndedPlayer1() : undefined}
onError={handleOnError(
player1Ref,
() => onEndedPlayer1(),
@@ -317,8 +322,9 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
controls={false}
height={0}
id="web-player-2"
loop={loopPlayer2}
muted={isMuted}
onEnded={src2 ? () => onEndedPlayer2() : undefined}
onEnded={src2 && !loopPlayer2 ? () => onEndedPlayer2() : undefined}
onError={handleOnError(
player2Ref,
() => onEndedPlayer2(),
@@ -112,7 +112,7 @@ export const useMainPlayerListener = () => {
decreaseVolume(volumeWheelStep);
});
mpvPlayerListener.rendererError((_event: any, message: string) => {
mpvPlayerListener.rendererError((message: string) => {
handleMpvError(message);
});
@@ -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,
},
});
@@ -4,6 +4,7 @@ import type ReactPlayer from 'react-player';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import {
WebPlayerEngine,
WebPlayerEngineHandle,
@@ -20,12 +21,13 @@ import {
usePlayerData,
usePlayerMuted,
usePlayerProperties,
usePlayerRepeat,
usePlayerStoreBase,
usePlayerVolume,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { QueueSong } from '/@/shared/types/domain-types';
import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
import { CrossfadeStyle, PlayerRepeat, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
const PLAY_PAUSE_FADE_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10;
@@ -34,6 +36,8 @@ export function WebPlayer() {
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
const { t } = useTranslation();
const { num, player1, player2, status } = usePlayerData();
const repeat = usePlayerRepeat();
const repeatOneProgressRef = useRef({ player1: 0, player2: 0 });
const { mediaAutoNext, mediaPause, setTimestamp } = usePlayerActions();
const playback = useMpvSettings();
const { webAudio } = useWebAudio();
@@ -97,12 +101,37 @@ export function WebPlayer() {
[],
);
const handleRepeatOne = useCallback(
(playerId: 1 | 2, playedSeconds: number, duration: number) => {
if (repeat !== PlayerRepeat.ONE || duration <= 0 || num !== playerId) {
return;
}
const key = playerId === 1 ? 'player1' : 'player2';
const last = repeatOneProgressRef.current[key];
repeatOneProgressRef.current[key] = playedSeconds;
if (last > duration * 0.85 && playedSeconds < duration * 0.15) {
setTimestamp(0);
eventEmitter.emit('PLAYER_REPEATED', {
index: usePlayerStoreBase.getState().player.index,
});
}
},
[num, repeat, setTimestamp],
);
const onProgressPlayer1 = useCallback(
(e: PlayerOnProgressProps) => {
if (!playerRef.current?.player1()) {
return;
}
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
return;
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
@@ -124,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(),
@@ -132,7 +162,17 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, transitionType, volume],
[
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player2,
repeat,
transitionType,
volume,
],
);
const onProgressPlayer2 = useCallback(
@@ -141,6 +181,11 @@ export function WebPlayer() {
return;
}
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
return;
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
@@ -162,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(),
@@ -170,7 +216,17 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume],
[
crossfadeDuration,
crossfadeStyle,
handleRepeatOne,
isTransitioning,
num,
player1,
repeat,
transitionType,
volume,
],
);
const handleOnEndedPlayer1 = useCallback(() => {
@@ -474,10 +530,15 @@ export function WebPlayer() {
});
}, [mediaPause, t]);
const loopPlayer1 = repeat === PlayerRepeat.ONE && num === 1;
const loopPlayer2 = repeat === PlayerRepeat.ONE && num === 2;
return (
<WebPlayerEngine
isMuted={isMuted}
isTransitioning={Boolean(isTransitioning)}
loopPlayer1={loopPlayer1}
loopPlayer2={loopPlayer2}
onEndedPlayer1={handleOnEndedPlayer1}
onEndedPlayer2={handleOnEndedPlayer2}
onErrorPause={handleOnErrorPause}
@@ -621,6 +682,7 @@ function exponentialEaseOut(t: number): number {
function gaplessHandler(args: {
currentTime: number;
duration: number;
hasNextSong: boolean;
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
@@ -629,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) {
@@ -0,0 +1,202 @@
import type { QueryClient } from '@tanstack/react-query';
import { autoDjGenreIdsForSongGenre, autoDjPushUniqueAlbumIds } from './auto-dj-utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffle } from '/@/renderer/utils/shuffle';
import {
AlbumListSort,
type QueueSong,
type ServerListItem,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjAlbumCollectArgs = {
albumStrategy: AutoDJStrategy;
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueAlbumIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
trySimilarSongs: boolean;
};
export const runAutoDjAlbumIds = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
switch (args.albumStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectAlbumsLibraryRandom(args);
}
default: {
return collectAlbumsSimilar(args);
}
}
};
const collectAlbumsLibraryRandom = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const page = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: Math.max(args.itemCount, 1),
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjAlbumLibraryRandom: args.currentSong?.id }),
});
const ids = page.items.map((a) => a.id).filter((id) => id && !args.queueAlbumIdSet.has(id));
return shuffle(ids).slice(0, args.itemCount);
};
const collectAlbumsSimilar = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const targetAlbumCount = args.itemCount;
const candidateAlbumIds: string[] = [];
const seenAlbumCandidates = new Set<string>();
if (args.trySimilarSongs && args.currentSong?.id) {
const similarSongsFromSimilarApi = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount * 4,
songId: args.currentSong.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
similarSongAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...similarSongsFromSimilarApi.map((s) => s.albumId),
);
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong && args.server) {
const genre = args.currentSong.genres?.[0];
if (genre) {
const genreIds = autoDjGenreIdsForSongGenre(genre, args.server.type);
const genreAlbums = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
genreIds,
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDj: genreIds,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...genreAlbums.items.map((album) => album.id),
);
if (!args.trySimilarSongs) {
const randomAlbumMixCount = Math.max(1, Math.ceil(50 * 0.2));
const randomAlbumsMix = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: randomAlbumMixCount,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDjMixRandom: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsMix.items.map((album) => album.id),
);
}
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const albumArtist = args.currentSong.albumArtists?.[0];
if (albumArtist) {
const albumsByArtist = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
artistIds: [albumArtist.id],
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
artistAlbumDj: albumArtist.id,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...albumsByArtist.items.map((album) => album.id),
);
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const randomAlbumsFallback = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: 80,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
fallbackAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsFallback.items.map((album) => album.id),
);
}
const shuffledAlbums = shuffle(candidateAlbumIds);
return shuffledAlbums.slice(0, targetAlbumCount);
};
@@ -0,0 +1,164 @@
import type { QueryClient } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import {
Played,
type QueueSong,
type ServerListItem,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjSongCollectArgs = {
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueSongIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
songStrategy: AutoDJStrategy;
trySimilarSongs: boolean;
};
export const runAutoDjSongs = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
switch (args.songStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectSongsLibraryRandom(args);
}
default: {
return collectSongsSimilar(args);
}
}
};
const collectSongsLibraryRandom = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
limit: Math.max(args.itemCount * 3, 50),
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjLibraryRandomSongs: args.currentSong.id }),
});
const pool = randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id));
const shuffled = shuffleInPlace(pool);
return shuffled.slice(0, args.itemCount);
};
const collectSongsSimilar = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
let uniqueSimilarSongs: Song[] = [];
if (args.trySimilarSongs) {
const similarSongs = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount,
songId: args.currentSong?.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: args.currentSong?.id }),
});
uniqueSimilarSongs = similarSongs.filter((song) => !args.queueSongIdSet.has(song.id));
}
if (uniqueSimilarSongs.length < args.itemCount) {
const genre = args.currentSong?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: args.currentSong?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
if (!args.trySimilarSongs) {
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId: args.serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
const randomSongsToAdd = uniqueRandomSongs.slice(0, randomSongCount);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const albumArtist = args.currentSong?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: args.currentSong?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
),
);
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
serverId: args.serverId,
}),
});
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)),
);
}
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
return shuffledSongs.slice(0, args.itemCount);
};
@@ -0,0 +1,28 @@
import type { Genre } from '/@/shared/types/domain-types';
import { ServerType } from '/@/shared/types/domain-types';
export const autoDjPushUniqueAlbumIds = (
accumulator: string[],
seenAlbums: Set<string>,
queueAlbumIdSet: Set<string>,
...ids: (string | undefined)[]
) => {
for (const id of ids) {
if (!id || queueAlbumIdSet.has(id) || seenAlbums.has(id)) continue;
seenAlbums.add(id);
accumulator.push(id);
}
};
export const autoDjGenreIdsForSongGenre = (genre: Genre, serverType: ServerType): string[] => {
if (serverType === ServerType.JELLYFIN) {
return [genre.id];
}
if (serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC) {
return [genre.name];
}
return [genre.id];
};
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
import {
InitialTimestampRestoreHook,
QueueRestoreTimestampHook,
} from '/@/renderer/features/player/hooks/use-queue-restore';
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
<RemoteHook />
<AutoDJHook />
<QueueRestoreTimestampHook />
<InitialTimestampRestoreHook />
<UpdateCurrentSongHook />
<RadioAudioInstanceHook />
<RadioMetadataHook />
@@ -14,6 +14,8 @@
}
.root {
width: 100%;
&:hover {
.bar {
background-color: var(--theme-colors-primary-filled);
@@ -44,48 +46,45 @@
transition: opacity 0.2s ease-in-out;
}
.track {
&::before {
right: calc(0.1rem * -1);
}
}
.slider-container {
display: flex;
--slider-value-width: 4.75rem;
--slider-value-gap: var(--theme-spacing-sm);
display: grid;
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
gap: var(--slider-value-gap);
align-items: center;
width: 95%;
height: 20px;
}
.slider-value-wrapper {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
max-width: 50px;
min-width: 0;
min-height: 0;
&:first-child {
justify-self: end;
}
&:last-child {
justify-self: start;
}
@media (width < 768px) {
display: none;
}
}
.slider-value-wrapper-elapsed {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 4.75rem;
min-height: 0;
@media (width < 768px) {
display: none;
@media (width < 768px) {
.slider-container {
grid-template-columns: minmax(0, 1fr);
}
}
.slider-wrapper {
display: flex;
flex: 6;
align-items: center;
width: 100%;
min-width: 0;
height: 100%;
}
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
return (
<>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapperElapsed}>
<div className={styles.sliderValueWrapper}>
<ScrobbleStatus formattedTime={formattedTime} />
</div>
<div className={styles.sliderWrapper}>
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
label: styles.label,
root: styles.root,
thumb: styles.thumb,
track: styles.track,
}}
{...props}
size={6}
@@ -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)';
@@ -1,5 +1,5 @@
import { t } from 'i18next';
import { useCallback, useEffect, useState, WheelEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, WheelEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
@@ -12,6 +12,9 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAppStoreActions,
useAutoDJSettings,
useCurrentServer,
@@ -34,7 +37,15 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Paper } from '/@/shared/components/paper/paper';
import { Popover } from '/@/shared/components/popover/popover';
import { Rating } from '/@/shared/components/rating/rating';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -90,28 +101,148 @@ const AutoDJButton = () => {
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const toggleAutoDJ = () => {
setSettings({
autoDJ: {
...settings,
enabled: !settings.enabled,
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
});
};
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const strategyLabels =
settings.mode === AUTO_DJ_MODE.ALBUMS
? {
description: '',
title: t('setting.autoDJ_albumStrategy'),
}
: {
description: '',
title: t('setting.autoDJ_songStrategy'),
};
const strategyValue =
settings.mode === AUTO_DJ_MODE.ALBUMS
? (settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR)
: (settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR);
return (
<Button
onClick={(e) => {
e.stopPropagation();
toggleAutoDJ();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
<Popover position="top-end" withArrow>
<Popover.Target>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
</Popover.Target>
<Popover.Dropdown maw={320} miw={260} onClick={(e) => e.stopPropagation()} p="sm">
<Stack gap="sm">
<Paper p="md" radius="md">
<Group align="center" gap="xs" justify="space-between" wrap="nowrap">
<Text fw={600} isNoSelect size="sm">
{t('setting.autoDJ_enabled')}
</Text>
<Switch
checked={settings.enabled}
onChange={(e) =>
setSettings({
autoDJ: { enabled: e.currentTarget.checked },
})
}
/>
</Group>
</Paper>
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{
label: t('setting.autoDJ_mode_albums'),
value: AUTO_DJ_MODE.ALBUMS,
},
]}
onChange={(value) =>
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
})
}
value={settings.mode}
w="100%"
/>
<Select
comboboxProps={{ withinPortal: false }}
data={strategySelectData}
description={strategyLabels.description}
label={strategyLabels.title}
onChange={(value) => {
if (!value) return;
setSettings({
autoDJ:
settings.mode === AUTO_DJ_MODE.ALBUMS
? { albumStrategy: value as AutoDJStrategy }
: { songStrategy: value as AutoDJStrategy },
});
}}
size="md"
value={strategyValue}
w="100%"
/>
<NumberInput
aria-label={itemLabels.title}
description={itemLabels.description}
hideControls={false}
label={itemLabels.title}
max={50}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
itemCount: Number(e),
},
})
}
size="md"
value={Number(settings.itemCount)}
/>
<NumberInput
aria-label={t('setting.autoDJ_timing')}
description={t('setting.autoDJ_timing_description')}
hideControls={false}
label={t('setting.autoDJ_timing')}
max={5}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
timing: Number(e),
},
})
}
size="md"
value={Number(settings.timing)}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
const capForDisplay = (value: number, limit: number) =>
limit > 0 ? Math.min(value, limit) : value;
const progressTowardLimit = (current: number, limit: number) =>
limit > 0 ? clampPct((current / limit) * 100) : 0;
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" />
);
@@ -34,21 +40,28 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
const hookInactive = !scrobbleEnabled || privateMode;
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
const listenPercentOfTrack =
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
const listenedSecRaw = snapshot.listenedMs / 1000;
const listenedSecDisplay = snapshot.submitted
? snapshot.targetDurationSec
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
const durationConditionPct =
snapshot.targetDurationSec > 0
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
: 0;
const percentConditionPct =
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
: 0;
const listenPercentOfTrackRaw =
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
const listenPercentDisplay = snapshot.submitted
? snapshot.targetPercentage
: capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
const durationConditionPct = progressTowardLimit(
listenedSecDisplay,
snapshot.targetDurationSec,
);
const percentConditionPct = progressTowardLimit(
listenPercentDisplay,
snapshot.targetPercentage,
);
return (
<HoverCard position="top" width={280}>
<HoverCard openDelay={500} position="top" width={280}>
<HoverCard.Target>
<Group
align="center"
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
<>
<Stack gap="xs">
<Text size="xs">
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
{`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
</Text>
<ScrobbleConditionProgress value={durationConditionPct} />
</Stack>
<Stack gap="xs">
<Text size="xs">
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
{`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
</Text>
<ScrobbleConditionProgress value={percentConditionPct} />
</Stack>
@@ -10,6 +10,7 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -18,9 +19,18 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
Played,
RandomSongListQuery,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ShuffleAllSlice extends RandomSongListQuery {
@@ -29,6 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
};
enableMaxYear: boolean;
enableMinYear: boolean;
playbackKind: 'albums' | 'songs';
}
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
@@ -42,16 +53,28 @@ const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
enableMaxYear: false,
enableMinYear: false,
genre: '',
limit: 100,
maxYear: 2020,
minYear: 2000,
musicFolder: '',
playbackKind: 'songs',
played: Played.All,
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
migrate: (persisted, version: number) => {
if (!persisted) {
return persisted;
}
if (version >= 2) {
return persisted;
}
return persisted;
},
name: 'store_shuffle_all',
version: 1,
version: 2,
},
),
);
@@ -66,13 +89,24 @@ export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => sta
export const ShuffleAllContextModal = () => {
const server = useCurrentServer();
const { addToQueueByData } = usePlayer();
const { addToQueueByData, addToQueueByFetch } = usePlayer();
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const {
enableMaxYear,
enableMinYear,
genre,
limit,
maxYear,
minYear,
musicFolderId,
playbackKind,
played,
} = useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const { isFetching, refetch } = useQuery({
const clampedLimit = Math.min(500, Math.max(1, limit || 100));
const { isFetching: isFetchingSongs, refetch: refetchSongs } = useQuery({
...randomFetchQuery({
query: {
genre: genre || undefined,
@@ -89,22 +123,75 @@ export const ShuffleAllContextModal = () => {
staleTime: 0,
});
const { isFetching: isFetchingAlbums, refetch: refetchAlbums } = useQuery({
...shuffleAlbumListQuery({
query: {
genreIds: genre ? [genre] : undefined,
limit: clampedLimit,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server.id,
}),
enabled: false,
gcTime: 0,
staleTime: 0,
});
const fetchTypeRef = useRef<Play>(null);
const handlePlay = async (playType: Play) => {
fetchTypeRef.current = playType;
const { data } = await refetch();
if (playbackKind === 'albums') {
const { data } = await refetchAlbums();
addToQueueByData(data?.items || [], playType);
addToQueueByFetch(
server.id,
data?.items.map((a) => a.id) ?? [],
LibraryItem.ALBUM,
playType,
);
} else {
const { data } = await refetchSongs();
addToQueueByData(data?.items || [], playType);
}
closeAllModals();
};
return (
<Stack gap="md">
<SegmentedControl
data={[
{
label: t('form.shuffleAll.input_kind_songs'),
value: 'songs',
},
{
label: t('form.shuffleAll.input_kind_albums'),
value: 'albums',
},
]}
onChange={(value) =>
setStore({
playbackKind: value as 'albums' | 'songs',
})
}
size="sm"
value={playbackKind}
w="100%"
/>
<NumberInput
label={t('form.shuffleAll.input_limit')}
label={
playbackKind === 'albums'
? t('form.shuffleAll.input_limit_albums')
: t('form.shuffleAll.input_limit_songs')
}
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
@@ -127,6 +214,7 @@ export const ShuffleAllContextModal = () => {
value={minYear}
/>
<NumberInput
disabled={playbackKind === 'albums'}
label={t('form.shuffleAll.input_maxYear')}
max={2050}
min={1850}
@@ -134,6 +222,7 @@ export const ShuffleAllContextModal = () => {
rightSection={
<Checkbox
checked={enableMaxYear}
disabled={playbackKind === 'albums'}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
@@ -144,7 +233,7 @@ export const ShuffleAllContextModal = () => {
<Suspense fallback={<Select data={[]} />}>
<GenreSelect />
</Suspense>
{server?.type === ServerType.JELLYFIN && (
{server?.type === ServerType.JELLYFIN && playbackKind === 'songs' && (
<Select
clearable
data={PLAYED_DATA}
@@ -156,10 +245,7 @@ export const ShuffleAllContextModal = () => {
/>
)}
<Divider />
<PlayButtonGroup
loading={(isFetching && fetchTypeRef.current) || false}
onPlay={handlePlay}
/>
<PlayButtonGroup loading={isFetchingSongs || isFetchingAlbums} onPlay={handlePlay} />
</Stack>
);
};
@@ -186,6 +272,13 @@ const randomFetchQuery = (args: {
});
};
const shuffleAlbumListQuery = (args: { query: AlbumListQuery; serverId: string }) => {
return albumQueries.list({
query: args.query,
serverId: args.serverId,
});
};
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},
@@ -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" />
+66 -127
View File
@@ -1,11 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { runAutoDjAlbumIds } from '/@/renderer/features/player/auto-dj/auto-dj-albums';
import { runAutoDjSongs } from '/@/renderer/features/player/auto-dj/auto-dj-songs';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
AUTO_DJ_STRATEGY,
isShuffleEnabled,
mapShuffledToQueueIndex,
useAutoDJSettings,
@@ -17,9 +18,8 @@ import {
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { hasFeature } from '/@/shared/api/utils';
import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
@@ -34,6 +34,9 @@ export const useAutoDJ = () => {
const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER);
useEffect(() => {
const albumStrategy = settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const songStrategy = settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const unsubscribe = usePlayerStoreBase.subscribe(
(state) => {
const queue = state.getQueue();
@@ -54,7 +57,6 @@ export const useAutoDJ = () => {
return;
}
// If no current song, don't autoplay
if (!properties.song?.id) {
return;
}
@@ -70,142 +72,76 @@ export const useAutoDJ = () => {
try {
const queue = usePlayerStore.getState().getQueue();
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
let uniqueSimilarSongs: Song[] = [];
const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0;
const musicFolderId =
hasMusicFolder && server?.musicFolderId ? server.musicFolderId : undefined;
const trySimilarSongs =
!hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder);
// Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs
if (trySimilarSongs) {
// First, try to fetch similar songs based on the current song
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: settings.itemCount,
songId: properties.song?.id,
},
serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }),
const runnerDepsBase = {
itemCount: settings.itemCount,
musicFolderId,
queryClient,
server,
serverId,
trySimilarSongs,
};
if (settings.mode === 'albums') {
if (!serverId) {
return;
}
const queueAlbumIdSet = new Set(
queue.items
.map((item) => item.albumId)
.filter((id): id is string => Boolean(id)),
);
const albumsToAdd = await runAutoDjAlbumIds({
...runnerDepsBase,
albumStrategy,
currentSong: properties.song,
queueAlbumIdSet,
});
uniqueSimilarSongs = similarSongs.filter(
(song) => !queueSongIdSet.has(song.id),
);
}
// If not enough songs, try to fetch more similar songs based on the genre of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const genre = properties.song?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: properties.song?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// If trySimilarSongs is false, add variation by mixing in random songs
if (!trySimilarSongs) {
// Calculate how many random songs we need: 20% or at least 1
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// Add minimum required random songs for variation
const randomSongsToAdd = uniqueRandomSongs.slice(
0,
randomSongCount,
);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
// If not enough songs, try to fetch more similar songs based on the album artist of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const albumArtist = properties.song?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: properties.song?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
),
);
}
}
// If not enough songs, just fetch fully random songs
if (uniqueSimilarSongs.length < settings.itemCount) {
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
if (albumsToAdd.length > 0) {
await player.addToQueueByFetch(
serverId,
}),
});
albumsToAdd,
LibraryItem.ALBUM,
Play.LAST,
);
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),
);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: albumsToAdd.length,
});
}
return;
}
// Shuffle the songs and then add to the queue
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
if (!serverId) {
return;
}
// Splice the first itemCount songs and add to the queue
const songsToAdd = shuffledSongs.slice(0, settings.itemCount);
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
// Add to the end of the queue
player.addToQueueByData(songsToAdd, Play.LAST);
// Emit event to trigger queue follow
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
const songsToAdd = await runAutoDjSongs({
...runnerDepsBase,
currentSong: properties.song,
queueSongIdSet,
songStrategy,
});
if (songsToAdd.length > 0) {
player.addToQueueByData(songsToAdd, Play.LAST);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
});
}
} catch (error) {
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {
category: LogCategory.PLAYER,
@@ -229,7 +165,10 @@ export const useAutoDJ = () => {
server,
serverId,
settings.enabled,
settings.albumStrategy,
settings.itemCount,
settings.mode,
settings.songStrategy,
settings.timing,
]);
};
@@ -117,11 +117,11 @@ export const useMPRIS = () => {
return;
}
mpris?.requestPosition((_e: unknown, data: { position: number }) => {
mpris?.requestPosition((data: { position: number }) => {
player.mediaSeekToTimestamp(data.position);
});
mpris?.requestSeek((_e: unknown, data: { offset: number }) => {
mpris?.requestSeek((data: { offset: number }) => {
player.mediaSkipForward(data.offset);
});
@@ -133,7 +133,7 @@ export const useMPRIS = () => {
player.toggleShuffle();
});
mpris?.requestVolume((_e: unknown, data: { volume: number }) => {
mpris?.requestVolume((data: { volume: number }) => {
player.setVolume(data.volume);
});
@@ -4,27 +4,27 @@ import React, { useCallback, useEffect } from 'react';
import { usePlayerStatus, useSettingsStore, useWindowSettings } from '/@/renderer/store';
import { PlayerStatus } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
export const usePowerSaveBlocker = () => {
const status = usePlayerStatus();
const { preventSleepOnPlayback, preventSuspendOnPlayback } = useWindowSettings();
const startPowerSaveBlocker = useCallback(async () => {
if (!ipc) return;
if (!utils) return;
try {
await ipc.invoke('power-save-blocker-start', { full: preventSleepOnPlayback });
await utils.startPowerSaveBlocker(preventSleepOnPlayback);
} catch (error) {
console.error('Failed to start power save blocker:', error);
}
}, [preventSleepOnPlayback]);
const stopPowerSaveBlocker = useCallback(async () => {
if (!ipc) return;
if (!utils) return;
try {
await ipc.invoke('power-save-blocker-stop');
await utils.stopPowerSaveBlocker();
} catch (error) {
console.error('Failed to stop power save blocker:', error);
}
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { api } from '/@/renderer/api';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
@@ -9,13 +9,20 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
setTimestamp,
useCurrentServerId,
usePlayerActions,
usePlayerHydrated,
usePlayerSong,
usePlayerStatus,
usePlayerStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { PlayerStatus } from '/@/shared/types/types';
let startupRestoreSessionHandled = false;
export const useQueueRestoreTimestamp = () => {
const player = usePlayerStore();
const { mediaSeekToTimestamp } = usePlayerActions();
usePlayerEvents(
{
@@ -24,7 +31,7 @@ export const useQueueRestoreTimestamp = () => {
setTimeout(() => {
setTimestamp(position);
player.mediaSeekToTimestamp(position);
mediaSeekToTimestamp(position);
}, 100);
},
},
@@ -37,6 +44,111 @@ export const QueueRestoreTimestampHook = () => {
return null;
};
export const useInitialTimestampRestore = () => {
const { mediaSeekToTimestamp } = usePlayerActions();
const playerHydrated = usePlayerHydrated();
const currentSong = usePlayerSong();
const playerStatus = usePlayerStatus();
const timestamp = useTimestampStoreBase((state) => state.timestamp);
const startupRestoreInitializedRef = useRef(false);
const startupSeekArmedRef = useRef<null | number>(null);
const startupSeekTargetUniqueIdRef = useRef<null | string>(null);
const startupSeekAppliedRef = useRef(false);
const cancelStartupSeek = useCallback(() => {
if (startupSeekAppliedRef.current) {
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);
}, [cancelStartupSeek, mediaSeekToTimestamp]);
useEffect(() => {
const targetUniqueId = startupSeekTargetUniqueIdRef.current;
if (
!targetUniqueId ||
startupSeekAppliedRef.current ||
!currentSong ||
currentSong._uniqueId === targetUniqueId
) {
return;
}
cancelStartupSeek();
}, [cancelStartupSeek, currentSong]);
useEffect(() => {
if (startupRestoreInitializedRef.current || startupRestoreSessionHandled) {
return;
}
if (!playerHydrated || !currentSong) {
return;
}
startupRestoreInitializedRef.current = true;
startupRestoreSessionHandled = true;
if (timestamp > 0) {
startupSeekArmedRef.current = timestamp;
startupSeekTargetUniqueIdRef.current = currentSong._uniqueId;
}
if (playerStatus === PlayerStatus.PLAYING) {
applyStartupSeek();
}
}, [applyStartupSeek, currentSong, playerHydrated, playerStatus, timestamp]);
usePlayerEvents(
{
onPlayerStatus: (properties) => {
if (properties.status === PlayerStatus.PLAYING) {
applyStartupSeek();
}
},
},
[applyStartupSeek],
);
};
export const InitialTimestampRestoreHook = () => {
useInitialTimestampRestore();
return null;
};
export const useSaveQueue = () => {
const serverId = useCurrentServerId();
@@ -58,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'),
});
},
});
@@ -4,17 +4,21 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import {
getServerById,
publishScrobbleDebug,
useAppStore,
usePlaybackSettings,
usePlayerSong,
usePlayerSpeed,
usePlayerStore,
useSettingsStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = {
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState();
};
const getPositionValue = (seconds: number, useTicks: boolean) => {
if (useTicks) {
return Math.round(seconds * 1e7);
}
return seconds * 1000;
};
/*
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If listened time meets the required percentage of track duration
@@ -99,6 +111,7 @@ export const useScrobble = () => {
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong();
const playbackRate = usePlayerSpeed();
const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
@@ -166,6 +179,8 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp;
const previousTime = prev.timestamp;
@@ -220,36 +235,37 @@ export const useScrobble = () => {
}
}
// Send Jellyfin progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
lastProgressEventRef.current = currentTime;
}
}
// 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;
// }
// }
// Check if we should submit scrobble based on listened time
if (!isCurrentSongScrobbledRef.current) {
@@ -261,20 +277,15 @@ export const useScrobble = () => {
});
if (shouldSubmitScrobble) {
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
// we just send the full duration of the song when it matches the local scrobble conditions
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentSong.duration * 1e7
: undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentSong.duration ?? 0, useTicks),
submission: true,
},
},
@@ -295,7 +306,7 @@ export const useScrobble = () => {
}
}
},
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
);
const handleScrobbleFromSongChange = useCallback(
@@ -305,6 +316,7 @@ export const useScrobble = () => {
) => {
const currentSong = properties.song;
const previousSong = previousSongRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
// Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) {
@@ -365,6 +377,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0,
submission: false,
},
@@ -388,11 +402,12 @@ export const useScrobble = () => {
flushScrobbleDebug();
},
[
flushScrobbleDebug,
scrobbleSettings?.notify,
isScrobbleEnabled,
isPrivateModeEnabled,
flushScrobbleDebug,
sendScrobble,
playbackRate,
],
);
@@ -404,6 +419,11 @@ 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;
if (!currentSong?.id) {
return;
@@ -422,7 +442,7 @@ export const useScrobble = () => {
}
// Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) {
if (!hasPlaybackReport) {
flushScrobbleDebug();
return;
}
@@ -436,19 +456,21 @@ export const useScrobble = () => {
return;
}
const position = properties.timestamp * 1e7;
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,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(properties.timestamp, useTicks),
submission: false,
},
},
@@ -465,7 +487,7 @@ export const useScrobble = () => {
);
flushScrobbleDebug();
},
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
);
const handleScrobbleFromStatus = useCallback(
@@ -475,18 +497,22 @@ 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;
if (!currentSong?.id) {
return;
}
// Only apply to Jellyfin controller scrobble
if (currentSong._serverType !== ServerType.JELLYFIN) {
if (!hasPlaybackReport) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const position = currentTimestamp * 1e7;
// Send pause event when status changes to paused
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
@@ -497,7 +523,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'pause',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
@@ -523,7 +551,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'unpause',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
@@ -542,7 +572,7 @@ export const useScrobble = () => {
flushScrobbleDebug();
},
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
);
const handleScrobbleFromRepeat = useCallback(() => {
@@ -552,6 +582,7 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
return;
@@ -570,6 +601,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0,
submission: false,
},
@@ -587,7 +620,7 @@ export const useScrobble = () => {
},
);
flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
// Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback(
@@ -607,20 +640,22 @@ export const useScrobble = () => {
}
const song = usePlayerStore.getState().getCurrentSong();
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = song?._serverType === ServerType.JELLYFIN;
if (!song?.id) {
return;
}
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: song._serverId || '' },
query: {
albumId: song.albumId,
id: song.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(song.duration ?? 0, useTicks),
submission: true,
},
},
@@ -659,7 +694,7 @@ export const useScrobble = () => {
});
return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
usePlayerEvents(
{
@@ -74,6 +74,7 @@ export const PlaylistListInfiniteTable = ({
columns={columns}
data={loadedItems}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}

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