Compare commits

..

140 Commits

Author SHA1 Message Date
jeffvli 4d57faa197 update to v1.0.1 2025-12-28 18:13:41 -08:00
Hosted Weblate 4129a8f56e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 69.2% (752 of 1086 strings)

Translated using Weblate (Polish)

Currently translated at 99.6% (1082 of 1086 strings)

Translated using Weblate (Polish)

Currently translated at 93.6% (1017 of 1086 strings)

Translated using Weblate (Polish)

Currently translated at 88.0% (955 of 1084 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (1084 of 1084 strings)

Translated using Weblate (Polish)

Currently translated at 87.8% (952 of 1084 strings)

Translated using Weblate (Czech)

Currently translated at 88.7% (961 of 1083 strings)

Translated using Weblate (Spanish)

Currently translated at 87.4% (947 of 1083 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 87.6% (948 of 1082 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (951 of 951 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (951 of 951 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Hungarian)

Currently translated at 100.0% (939 of 939 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (939 of 939 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (939 of 939 strings)

Translated using Weblate (Turkish)

Currently translated at 74.8% (701 of 936 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (936 of 936 strings)

Translated using Weblate (Korean)

Currently translated at 45.9% (430 of 936 strings)

Translated using Weblate (German)

Currently translated at 94.8% (888 of 936 strings)

Translated using Weblate (Swedish)

Currently translated at 41.1% (385 of 936 strings)

Translated using Weblate (German)

Currently translated at 94.8% (888 of 936 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (936 of 936 strings)

Translated using Weblate (Dutch)

Currently translated at 52.4% (491 of 936 strings)

Translated using Weblate (Swedish)

Currently translated at 39.6% (371 of 936 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (936 of 936 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (936 of 936 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.4% (927 of 932 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.4% (927 of 932 strings)

Translated using Weblate (French)

Currently translated at 99.8% (927 of 928 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (927 of 927 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (927 of 927 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (926 of 926 strings)

Translated using Weblate (French)

Currently translated at 100.0% (926 of 926 strings)

Co-authored-by: Dylan MONTIGAUD <dylanmontigaud17@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Fredrik Wastring <fredrik.was@gmail.com>
Co-authored-by: Guanjun Shi <yzsgjcdd@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Mücahit Kaya <kaya-mucahit@outlook.com>
Co-authored-by: Naim V <naimverboom@gmail.com>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: Soderes Sanyi <kennex@protonmail.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>
Co-authored-by: wonkwan lee <gorathkr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/hu/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/sv/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/tr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2025-12-29 03:12:30 +01:00
jeffvli 1aa91fe2f5 add defaults to main settings to sync with renderer 2025-12-28 18:12:14 -08:00
jeffvli 5c399f7117 add timeout to prevent too many lyrics fetch on song change 2025-12-28 17:49:39 -08:00
jeffvli 3c07f03651 fix custom gradients in audiomotion visualizer 2025-12-28 17:45:21 -08:00
jeffvli b26b6eab09 move server selector inline with other collapsed sidebar items 2025-12-28 17:27:01 -08:00
jeffvli b2579c031d pin album/song list filters by default 2025-12-28 17:19:38 -08:00
jeffvli 98e2458a03 enable lyrics/visualizer in sidebar by default 2025-12-28 17:19:38 -08:00
jeffvli b30e26ae7e adjust app default columns 2025-12-28 17:19:32 -08:00
jeffvli fd158b956a reorganize album metadata tags 2025-12-28 16:27:27 -08:00
jeffvli 109788ebbb update multiselect styles 2025-12-28 15:07:57 -08:00
jeffvli ffdef596ad auto follow queue when autodj is triggered 2025-12-28 14:34:38 -08:00
jeffvli 0a54f7c44c remove similarSongs from autodj when music folder is selected (#1451)
- Similar songs queries for both Subsonic/Jellyfin do not support querying by musicFolderId
2025-12-28 14:09:22 -08:00
jeffvli d5d995de5f fix missing musicFolderId on search (#1451) 2025-12-28 13:34:26 -08:00
jeffvli 304c38db1e fix infinite loader potentially refetching count multiple times on query change 2025-12-28 06:55:58 -08:00
jeffvli ef631d12cc support multiselect for nd album artist_id filter 2025-12-28 06:44:31 -08:00
jeffvli 4006980b29 adjust artist page suspense fetching 2025-12-28 06:36:14 -08:00
jeffvli f9c3c107bd adjust featured genres breakpoints 2025-12-28 06:04:54 -08:00
jeffvli 7106b100ce set default playerbar type to Slider 2025-12-28 06:04:41 -08:00
jeffvli e2d56c70b1 add optimistic update for top songs list (#1414) 2025-12-28 03:47:01 -08:00
jeffvli 1a930021b6 handle favorite/update in similarArtists 2025-12-28 03:43:59 -08:00
jeffvli 66699b9572 fix regression on round ItemCard 2025-12-28 03:23:57 -08:00
jeffvli 99be12e648 improve list loading indicator 2025-12-28 03:05:20 -08:00
jeffvli dde4e1b33c improve image placeholders and loading 2025-12-28 02:43:31 -08:00
jeffvli 88711eac2f fix Jellyfin imageId normalization 2025-12-28 02:26:27 -08:00
jeffvli f21ca83179 force single line on feature carousel artist 2025-12-28 02:03:20 -08:00
jeffvli f43950874d use "album" releasetype as default artist album fallback 2025-12-28 02:03:05 -08:00
jeffvli a3794158f0 increase default sidebar image res to 400 2025-12-28 01:34:18 -08:00
jeffvli 7f52b31b40 use multiselect for navidrome song tag filters 2025-12-28 01:26:36 -08:00
jeffvli 18a864a049 use multiselect for navidrome tag filters (#1420) 2025-12-28 01:24:54 -08:00
jeffvli 4eac6457ea add MPRIS volume handler (#1415) 2025-12-28 01:15:38 -08:00
Leonardo Salgueiro df0d4b7032 feat: adding setting to show/hide user ratings (#1426)
* adding show/hide user ratings setting
2025-12-28 01:04:50 -08:00
jeffvli f0942c7795 revert spinner icon 2025-12-28 01:04:11 -08:00
jeffvli 396325f397 refactor artist album grid to use flex layout 2025-12-28 00:51:20 -08:00
BatteredBunny 63015195b0 feat: Add DISABLE_AUTO_UPDATES env variable for disabling updates in the application entirely (#1446) 2025-12-28 00:50:55 -08:00
jeffvli e821397e6c add horizontal scroll to feature carousel (#1123) 2025-12-27 18:29:10 -08:00
jeffvli aae68853ef kill butterchurn visualizer if player is idle 2025-12-27 18:18:06 -08:00
jeffvli f904aafd4a add isEnabled property for playback filters 2025-12-27 18:12:14 -08:00
jeffvli 38b2508de6 unrender sidebar play queue when fullscreen player is open 2025-12-27 17:50:42 -08:00
jeffvli 8fee57157a fix lyrics settings button variant 2025-12-27 17:44:41 -08:00
jeffvli 6207cea9f1 support animated artist images 2025-12-27 17:29:26 -08:00
jeffvli c299752e44 Revert "enable autoHideSuspend on all overlayscrollbars instances"
This reverts commit b9a0d9b847.
2025-12-27 17:06:09 -08:00
jeffvli 82e4f832eb fix queue panels size persistence 2025-12-27 16:59:30 -08:00
jeffvli c8221c07ef fix layout shift caused by sidebar panel divider 2025-12-27 16:22:19 -08:00
jeffvli 710fc16f62 adjust artist album grid span 2025-12-27 16:22:02 -08:00
jeffvli 331cddcabb allow query preview when query editor is closed 2025-12-27 15:57:43 -08:00
jeffvli b9a0d9b847 enable autoHideSuspend on all overlayscrollbars instances 2025-12-27 15:48:50 -08:00
jeffvli 9c59a38f7a improve performance on page ScrollArea by throttling scroll callback 2025-12-27 15:48:29 -08:00
jeffvli b573999d33 add titles translations for secondary release types 2025-12-27 15:44:27 -08:00
jeffvli 35d8698ca0 add broadcast and other release type groupings 2025-12-27 15:23:42 -08:00
jeffvli 23e4574667 add configuration to show secondary release types 2025-12-27 15:19:00 -08:00
jeffvli 7db15c7c72 separate EP and Single in artist releasetypes 2025-12-27 15:03:38 -08:00
jeffvli d94b220319 fix playlist image urls 2025-12-27 14:58:57 -08:00
jeffvli acfc106f40 fix artist page initial fetch 2025-12-27 14:57:58 -08:00
jeffvli 856400048b fix image on context menu preview 2025-12-27 14:25:57 -08:00
jeffvli a7c2a92f16 set fixed height for play queue controls 2025-12-27 02:46:21 -08:00
jeffvli fc3d700a57 fix styling of transparent variant button disabled state 2025-12-27 02:44:09 -08:00
jeffvli a60973ffee adjust page animation transition 2025-12-27 02:42:41 -08:00
jeffvli a1114235d6 add layout animation for artist album cards 2025-12-27 02:25:13 -08:00
jeffvli 928b0b6f4d add a limit of 20 albums per section 2025-12-27 02:15:13 -08:00
jeffvli 60d6d49eaa fix height of sidebar panel handles 2025-12-27 02:07:03 -08:00
jeffvli 804a670bf1 default artistBackground to true 2025-12-27 02:04:00 -08:00
jeffvli e51bb05564 add play controls to artist album sections 2025-12-27 02:03:53 -08:00
jeffvli a21ee21652 add album section count badge 2025-12-27 01:55:34 -08:00
jeffvli 8b2d162733 adjust grid column counts based on new breakpoint configuration 2025-12-27 01:43:48 -08:00
jeffvli cc76c9f31e update name of Single section to Single EP 2025-12-27 01:33:29 -08:00
jeffvli aa7a5037fa add opacity configuration for visualizers 2025-12-27 01:33:05 -08:00
jeffvli 0acb1f54fc lint files 2025-12-27 01:27:10 -08:00
jeffvli 403ed8cae6 move artist radio button to page content 2025-12-27 01:19:56 -08:00
jeffvli 3db229ef68 re-add similar artists carousel to artist page 2025-12-27 01:13:28 -08:00
jeffvli f0d22267c3 replace react-resizable-panels with react-split-pane
- react-resizable-panels was causing an issue with browser navigation
2025-12-27 00:55:38 -08:00
jeffvli 796e511626 redesign artist page (#416) (#447) 2025-12-26 21:13:04 -08:00
jeffvli 06e757d3b2 decrease default lyric font/gap size 2025-12-26 18:40:30 -08:00
jeffvli 0c8032d097 adjust default audiomotion visualizer styles 2025-12-26 15:31:26 -08:00
jeffvli 7cd86d1301 default enable lyrics/visualizer in sidebar 2025-12-26 15:31:17 -08:00
jeffvli 781df3ab06 remove accordion from table/grid config 2025-12-26 15:16:51 -08:00
jeffvli 88b9124185 replace themes scrollbar with neutral colors 2025-12-26 15:14:01 -08:00
jeffvli 48724f816c fix compact item card styling for light mode 2025-12-26 15:09:43 -08:00
jeffvli 1a184a73de revert behavior for queue add by id 2025-12-26 14:55:53 -08:00
jeffvli e4b5cf36e1 fix duplicate PLAYER_PLAY event causing race condition in mpv 2025-12-26 14:49:06 -08:00
jeffvli dff182cbc5 use correct id for runtime images in useItemImageUrl 2025-12-26 14:04:48 -08:00
jeffvli fb8245539f fix missing normalizations for subsonic imageId (#1449) 2025-12-26 14:04:04 -08:00
jeffvli bb3cb4a6ad fix mpv volume on initial startup 2025-12-26 05:24:44 -08:00
jeffvli fb2e30c484 remove padding from sidebar visualizer 2025-12-26 02:32:10 -08:00
jeffvli 73c5292cc1 move the visualizer settings out of error boundary 2025-12-26 02:24:20 -08:00
jeffvli 800074dced add ignored presets for butterchurn 2025-12-25 17:19:56 -08:00
farfromrefuge f78a572a3c fix:music filtering subsonic (#1442)
* Add musicFolderId filtering for tracks in Subsonic API

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-25 14:22:23 -08:00
jeffvli 97b20cec19 persist queue sidebar panel sizing 2025-12-25 13:13:32 -08:00
jeffvli fd833f683b fix lyric alignment select on fullscreen player config (#1390) 2025-12-25 01:49:59 -08:00
jeffvli 076d9b3083 re-add pause / unpause events to Jellyfin scrobble (#1413) 2025-12-25 01:47:04 -08:00
jeffvli 3a2a1b0dc8 prevent sidebar item conflict with server selector 2025-12-25 01:39:33 -08:00
jeffvli 20c19cac6f add server selector to collapsed sidebar (#1401) 2025-12-25 01:37:07 -08:00
jeffvli 8205eeed22 split lyrics settings by key (#1389) 2025-12-25 01:29:31 -08:00
jeffvli 5eb2cff6e9 add additional configuration to player sidebar
- allow reordering of panels
- allow separation between lyrics and visualizer panels
- allow resize of panels
2025-12-24 23:20:00 -08:00
jeffvli d822d9cd29 increase default table image res to 80px 2025-12-24 19:25:44 -08:00
jeffvli 4142132ebc removal modal opacity on visualizer settings modal 2025-12-24 19:21:28 -08:00
jeffvli fb2746323b fix missing radial spin speed setting 2025-12-24 19:19:02 -08:00
Jeff d9172efae9 Add visualizer configuration (#1443)
* add visualizer configuration

* add visualizer presets

* add butterchurn visualizer

* wrap visualizers in error boundary
2025-12-24 18:12:13 -08:00
jeffvli 8e04f98e26 fix expand button on compact ItemCard 2025-12-24 03:17:39 -08:00
jeffvli 51587fbb6b add grid item card size presets 2025-12-23 21:59:52 -08:00
jeffvli cf06d69822 add sidebar visualizer / lyrics toggle in player config 2025-12-23 21:41:33 -08:00
jeffvli 22751de2f6 fix image url generation on album detail header 2025-12-23 21:30:26 -08:00
Kendall Garner 04fbf5d3d2 Export lyrics (#1383)
* add export button to the lyrics actions

* add export button to the lyrics search modal

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2025-12-23 21:27:47 -08:00
jeffvli 936ba73fe4 remove refresh buttons from grid carousel by default 2025-12-23 21:27:12 -08:00
jeffvli 05efd0f318 temporarily remove item list selection dialog 2025-12-23 21:05:50 -08:00
jeffvli ce570eddd2 improve auto dj to trigger on remaining songs change 2025-12-23 21:05:40 -08:00
jeffvli 5b1f269344 update radio permission to allow normal users to create 2025-12-23 20:58:58 -08:00
jeffvli 0806d9852a fix sidebar playlist image url generation 2025-12-23 20:53:14 -08:00
jeffvli c3e38d7133 fix drag preview image url generation 2025-12-23 20:53:07 -08:00
farfromrefuge a322717e0e feat: add artist radio and track radio (in context menu) (#1437)
* Add API support for artist radio and track radio features

* Add translation strings and settings UI for artist radio count

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2025-12-23 20:46:19 -08:00
jeffvli dcb84dd442 only run PR build on src changes 2025-12-23 20:26:24 -08:00
jeffvli ac257a9dc1 update PR publish to wait for lint success 2025-12-23 20:23:53 -08:00
Jeff 25bfb65b6d Add image URL generation at runtime to allow for dynamic image sizes (#1439)
* add getImageUrl to domain endpoints

* add new ItemImage component and hooks to generate image url

* add configuration for image resolution based on types
2025-12-23 20:18:52 -08:00
jeffvli 96f38e597c fix SegmentedControl indicator bugged position 2025-12-23 18:19:52 -08:00
Kendall Garner 383b728ddc chore(readmee): more accurate docker compose (2) 2025-12-23 17:39:11 -05:00
Kendall Garner 003cfbdd6c chore(readmer): more accurate docker compose 2025-12-23 17:38:13 -05:00
jeffvli a67ae50d16 fix links in album description 2025-12-22 20:43:26 -08:00
jeffvli ac3dcb5e17 fix releaseDate behavior on navidrome song normalization 2025-12-22 20:15:37 -08:00
Fleeym 833f82edff improve network error detection (#1436) 2025-12-22 20:15:06 -08:00
Fleeym 76f55111ec improve network error route (#1435) 2025-12-22 20:03:06 -08:00
Tiberiu (ELECTRO) Lazăr f418bbfd2f Fix API parameter for Jellyfin 10.12 and up (#1429)
* Fix API parameter for Jellyfin 10.11 and up

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2025-12-22 19:51:06 -08:00
Fleeym b02eba510d set minimum ServerRequired list size to 1 (#1434)
The server list in ServerRequired was expecting at least 2 items before it was shown, meaning that if you are on the login screen, you couldn't select your configured server and had to add another one.
2025-12-22 19:45:40 -08:00
Benjamin 7a77b9bfe7 feat: show record label copyright at bottom of track listing (#1421)
* show record label copyright at bottom of track listing

* add recordLabels support for Jellyfin

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2025-12-22 19:08:35 -08:00
Exceen c34b6774b9 fixed favorite tracks/albums/artists selection (#1418) 2025-12-22 18:43:16 -08:00
Kendall Garner f3fe5b013a fix(subsonic): support favoriting/unfavoriting artist 2025-12-19 21:35:26 -05:00
jeffvli 37be2cc8fa lint files 2025-12-16 21:10:55 -08:00
jeffvli e3c26aa5fa remove selection dialog from certain list views 2025-12-16 21:06:56 -08:00
jeffvli e21f538aa4 add item list selection dialog 2025-12-16 20:24:46 -08:00
jeffvli c9cd87bae5 remove release_channel from settings sync 2025-12-15 23:17:47 -08:00
jeffvli 9a8cb45510 Revert "prevent autoupdater from setting release channel (#1396)"
This reverts commit 614761efd7.
2025-12-15 23:06:25 -08:00
jeffvli 68b6a58ac5 handle text overflow on sidebar playlist duration 2025-12-15 21:37:58 -08:00
jeffvli 5b5cdbfb7f prevent action bar icons from being squished on resize (fix) 2025-12-15 21:37:17 -08:00
jeffvli cf4e505743 prevent action bar icons from being squished on resize 2025-12-15 21:36:27 -08:00
jeffvli 8464ed439e use success notification instead of warn on 0 playlist entries added (#1393) 2025-12-15 21:15:20 -08:00
jeffvli 9e49a45db9 fix initial list order on artist discography (#1378) 2025-12-15 21:07:24 -08:00
jeffvli 8dc5f2a580 add page view tracker 2025-12-15 20:50:58 -08:00
jeffvli 6bb848a675 remove analytics properties for based on desktop-only settings 2025-12-15 20:31:15 -08:00
jeffvli 8edf61f9e7 localize dates (#1237) 2025-12-15 20:20:32 -08:00
jeffvli 96d2699a2d add migration to clear all original store settings (#1396) 2025-12-15 19:07:12 -08:00
jeffvli 614761efd7 prevent autoupdater from setting release channel (#1396) 2025-12-15 18:12:16 -08:00
260 changed files with 28269 additions and 2144 deletions
+15
View File
@@ -4,9 +4,24 @@ on:
pull_request:
branches:
- development
paths:
- 'src/**'
jobs:
wait-for-lint:
runs-on: ubuntu-latest
steps:
- name: Wait for Test workflow to complete
uses: lewagon/wait-on-check-action@v1.4.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: 'lint'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
allowed-conclusions: success
publish:
needs: wait-for-lint
runs-on: ${{ matrix.os }}
strategy:
+5 -8
View File
@@ -105,18 +105,15 @@ services:
feishin:
container_name: feishin
image: 'ghcr.io/jeffvli/feishin:latest'
restart: unless-stopped
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_NAME=jellyfin # pre-defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port
- PUID=1000
- PGID=1000
- UMASK=002
- TZ=America/Los_Angeles
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
- SERVER_URL= # http://address:port or https://address:port
ports:
- 9180:9180
restart: unless-stopped
# Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
```
### Configuration
+7 -7
View File
@@ -1,13 +1,13 @@
version: '3.5'
services:
feishin:
container_name: feishin
image: ghcr.io/jeffvli/feishin:latest
image: 'ghcr.io/jeffvli/feishin:latest'
restart: unless-stopped
environment:
- SERVER_NAME=jellyfin # pre-defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
- SERVER_URL= # http://address:port or https://address:port
ports:
- 9180:9180
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port
# Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
+18279
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.22.0",
"version": "1.0.1",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -82,6 +82,8 @@
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.1",
"axios": "^1.13.2",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"cheerio": "^1.1.2",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -121,6 +123,7 @@
"react-loading-skeleton": "^3.5.0",
"react-player": "^2.16.0",
"react-router": "^7.9.6",
"react-split-pane": "^3.0.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.3",
+65 -3
View File
@@ -71,6 +71,12 @@ importers:
axios:
specifier: ^1.13.2
version: 1.13.2
butterchurn:
specifier: ^2.6.7
version: 2.6.7
butterchurn-presets:
specifier: ^2.4.7
version: 2.4.7
cheerio:
specifier: ^1.1.2
version: 1.1.2
@@ -188,6 +194,9 @@ importers:
react-router:
specifier: ^7.9.6
version: 7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-split-pane:
specifier: ^3.0.4
version: 3.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-virtualized-auto-sizer:
specifier: ^1.0.26
version: 1.0.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2263,6 +2272,9 @@ packages:
peerDependencies:
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
babel-runtime@6.26.0:
resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2359,6 +2371,12 @@ packages:
builder-util@26.0.11:
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
butterchurn-presets@2.4.7:
resolution: {integrity: sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==}
butterchurn@2.6.7:
resolution: {integrity: sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -2555,6 +2573,10 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
core-js@2.6.12:
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -2779,6 +2801,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecma-proposal-math-extensions@0.0.2:
resolution: {integrity: sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -4621,6 +4646,13 @@ packages:
react-dom:
optional: true
react-split-pane@3.0.4:
resolution: {integrity: sha512-+QNayN8lsYhT87z0bH5yAuUocoqHlc3AQnw/+pGXMH2kG2+mSfNAR4fHhEdmweHLFjIyX811hh9sgCkiHXCYag==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4688,6 +4720,9 @@ packages:
regenerate@1.4.2:
resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==}
regenerator-runtime@0.11.1:
resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@@ -7998,6 +8033,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-runtime@6.26.0:
dependencies:
core-js: 2.6.12
regenerator-runtime: 0.11.1
balanced-match@1.0.2: {}
balanced-match@2.0.0: {}
@@ -8134,6 +8174,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
butterchurn-presets@2.4.7:
dependencies:
babel-runtime: 6.26.0
ecma-proposal-math-extensions: 0.0.2
lodash: 4.17.21
butterchurn@2.6.7:
dependencies:
'@babel/runtime': 7.28.4
ecma-proposal-math-extensions: 0.0.2
cac@6.7.14: {}
cacache@16.1.3:
@@ -8361,6 +8412,8 @@ snapshots:
dependencies:
browserslist: 4.28.0
core-js@2.6.12: {}
core-util-is@1.0.2:
optional: true
@@ -8604,6 +8657,8 @@ snapshots:
eastasianwidth@0.2.0: {}
ecma-proposal-math-extensions@0.0.2: {}
ejs@3.1.10:
dependencies:
jake: 10.9.2
@@ -9526,7 +9581,7 @@ snapshots:
i18next@24.2.3(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.8.3
@@ -10588,6 +10643,11 @@ snapshots:
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react-split-pane@3.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-style-singleton@2.2.3(@types/react@19.2.5)(react@19.1.0):
dependencies:
get-nonce: 1.0.1
@@ -10598,7 +10658,7 @@ snapshots:
react-textarea-autosize@8.5.9(@types/react@19.2.5)(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
react: 19.1.0
use-composed-ref: 1.4.0(@types/react@19.2.5)(react@19.1.0)
use-latest: 1.3.0(@types/react@19.2.5)(react@19.1.0)
@@ -10607,7 +10667,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.28.4
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -10672,6 +10732,8 @@ snapshots:
regenerate@1.4.2: {}
regenerator-runtime@0.11.1: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
+52 -13
View File
@@ -14,7 +14,8 @@
"tracks": "$t(entity.track_other)",
"nowPlaying": "ara sona",
"shared": "$t(entity.playlist_other) compartida",
"favorites": "$t(entity.favorite_other)"
"favorites": "$t(entity.favorite_other)",
"radio": "$t(entity.radioStation_other)"
},
"albumArtistDetail": {
"relatedArtists": "$t(entity.artist_other) similars",
@@ -184,6 +185,9 @@
},
"folderList": {
"title": "$t(entity.folder_other)"
},
"radioList": {
"title": "emissores de ràdio"
}
},
"common": {
@@ -301,7 +305,8 @@
"sort": "ordre",
"gridRows": "files de la quadrícula",
"tableColumns": "columnes de la taula",
"itemsMore": "{{count}} més"
"itemsMore": "{{count}} més",
"countSelected": "{{count}} seleccionats"
},
"entity": {
"album_one": "àlbum",
@@ -355,7 +360,13 @@
"song_other": "cançons",
"favorite_one": "preferit",
"favorite_many": "preferits",
"favorite_other": "preferits"
"favorite_other": "preferits",
"radioStation_one": "emissora de ràdio",
"radioStation_many": "emissores de ràdio",
"radioStation_other": "emissores de ràdio",
"radioStationWithCount_one": "{{count}} emissora de ràdio",
"radioStationWithCount_many": "{{count}} emissores de ràdio",
"radioStationWithCount_other": "{{count}} emissores de ràdio"
},
"form": {
"addToPlaylist": {
@@ -445,6 +456,16 @@
"input_played_optionAll": "totes les pistes",
"input_played_optionUnplayed": "només les pistes sense reproduir",
"input_played_optionPlayed": "només les pistes reproduïdes"
},
"createRadioStation": {
"success": "emissora de ràdio creada amb èxit",
"title": "crea una emissora de ràdio",
"input_homepageUrl": "URL de la pàgina d'inici",
"input_name": "nom",
"input_streamUrl": "URL de transmissió"
},
"saveQueue": {
"success": "cua de reproducció desada al servidor"
}
},
"action": {
@@ -479,7 +500,13 @@
"shuffle": "mescla",
"shuffleAll": "mescla-ho tot",
"shuffleSelected": "mescla els seleccionats",
"viewMore": "mostra'n més"
"viewMore": "mostra'n més",
"createRadioStation": "crea $t(entity.radioStation_one)",
"deleteRadioStation": "elimina $t(entity.radioStation_one)",
"addOrRemoveFromSelection": "afegeix o elimina de la selecció",
"selectRangeOfItems": "selecciona un interval d'elements",
"selectAll": "selecciona-ho tot",
"openApplicationDirectory": "obre el directori de l'aplicació"
},
"setting": {
"language_description": "estableix l'idioma de l'aplicació ($t(common.restartRequired))",
@@ -643,8 +670,6 @@
"playbackStyle_optionNormal": "normal",
"playButtonBehavior": "comportament del botó de reproducció",
"playButtonBehavior_description": "estableix el comportament predeterminat del botó de reproducció quan s'afegeixen cançons a la cua",
"playerAlbumArtResolution": "resolució de la caràtula de l'àlbum al reproductor",
"playerAlbumArtResolution_description": "la resolució de la previsualització gran de la caràtula al reproductor. si és més alta, serà més nítida, però es carregarà més lent. el valor predeterminat 0 vol dir automàtic",
"playerbarOpenDrawer": "activa el reproductor en pantalla completa",
"playerbarOpenDrawer_description": "permet fer clic a la barra de reproducció per obrir el reproductor de pantalla completa",
"remotePassword": "contrasenya del servidor de control remot",
@@ -787,7 +812,9 @@
"queryBuilderCustomFields_inputLabel": "discogràfica",
"queryBuilderCustomFields_inputTag": "etiqueta",
"queryBuilderCustomFields": "camps personalitzats",
"queryBuilderCustomFields_description": "afegeix camps personalitzats pel constructor de consultes"
"queryBuilderCustomFields_description": "afegeix camps personalitzats pel constructor de consultes",
"useThemeAccentColor": "fes servir el color d'accent del tema",
"useThemeAccentColor_description": "fes servir el color primari definit pel tema seleccionat en comptes del color d'accent personalitzat"
},
"table": {
"column": {
@@ -953,8 +980,8 @@
"repeat_all": "repetició",
"shuffle": "reprodueix (mesclat)",
"shuffle_off": "reproducció aleatòria desactivada",
"addLast": "afegeix al final",
"addNext": "afegeix a continuació",
"addLast": "al final",
"addNext": "a continuació",
"favorite": "marcar com a preferida",
"mute": "silencia",
"next": "següent",
@@ -970,12 +997,15 @@
"toggleFullscreenPlayer": "activa el reproductor de pantalla completa",
"unfavorite": "elimina de preferits",
"pause": "pausa",
"addLastShuffled": "afegeix al final (mesclat)",
"addNextShuffled": "afegeix a continuació (mesclat)",
"addLastShuffled": "al final (mesclat)",
"addNextShuffled": "a continuació (mesclat)",
"holdToShuffle": "mantén premut per mesclar",
"queueType": "tipus de cua",
"queueType_default": "predeterminat",
"queueType_priority": "prioritat"
"queueType_priority": "prioritat",
"lyrics": "lletra",
"restoreQueueFromServer": "restaura la cua del servidor",
"saveQueueToServer": "desa la cua al servidor"
},
"error": {
"credentialsRequired": "credencials requerides",
@@ -1001,7 +1031,10 @@
"notificationDenied": "s'han negat els permisos per enviar notificacions. aquesta opció no té cap efecte",
"playbackError": "hi ha hagut un error en intentar reproduir el mitjà",
"remoteDisableError": "hi ha hagut un error en intentar $t(common.disable) el servidor remot",
"endpointNotImplementedError": "el punt final {{endpoint}} no està implementat per {{serverType}}"
"endpointNotImplementedError": "el punt final {{endpoint}} no està implementat per {{serverType}}",
"multipleServerSaveQueueError": "la cua de reproducció té una o més cançons que no són del servidor actual, cosa que no és compatible",
"saveQueueFailed": "error en desar la cua",
"settingsSyncError": "hi ha discrepàncies entre la configuració del renderitzador i el procés principal. reinicieu l'aplicació per aplicar els canvis"
},
"releaseType": {
"primary": {
@@ -1055,5 +1088,11 @@
"queryBuilder": {
"standardTags": "etiquetes estàndard",
"customTags": "etiquetes personalitzades"
},
"datetime": {
"minuteShort": "min",
"secondShort": "s",
"hourShort": "h",
"dayShort": "d"
}
}
+186 -8
View File
@@ -39,7 +39,9 @@
"holdToShuffle": "podržte pro zamíchání",
"lyrics": "texty",
"restoreQueueFromServer": "obnovit frontu ze serveru",
"saveQueueToServer": "uložit frontu na server"
"saveQueueToServer": "uložit frontu na server",
"artistRadio": "rádio umělce",
"trackRadio": "rádio skladby"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -207,8 +209,6 @@
"passwordStore": "ukládání hesel / tajných klíčů",
"mpvExtraParameters_help": "jeden na řádek",
"homeConfiguration": "nastavení domovské stránky",
"playerAlbumArtResolution_description": "rozlišení náhledu obalu alba ve velkém přehrávači. větší hodnota znamená kvalitnější obrázek, ale může se déle načítat. výchozí hodnota je 0, což znamená automatické rozlišení",
"playerAlbumArtResolution": "rozlišení obalu alba v přehrávači",
"externalLinks_description": "zapne zobrazování externích odkazů (Last.fm, MusicBrainz) na stránce umělce/alba",
"clearCacheSuccess": "mezipaměť úspěšně vymazána",
"externalLinks": "zobrazit externí odkazy",
@@ -349,7 +349,18 @@
"logLevel_optionInfo": "informace",
"logLevel_optionWarn": "varování",
"useThemeAccentColor": "použít barvu motivu",
"useThemeAccentColor_description": "použít primární barvu definovanou ve zvoleném motivu namísto vlastní barvy rozhraní"
"useThemeAccentColor_description": "použít primární barvu definovanou ve zvoleném motivu namísto vlastní barvy rozhraní",
"artistRadioCount_description": "nastaví počet skladeb, které načíst pro rádio umělce a rádio skladby",
"artistRadioCount": "počet skladeb pro rádio umělce/skladby",
"imageResolution": "rozlišení obrázků",
"imageResolution_description": "rozlišení obrázků používaných napříč aplikací. nastavení hodnoty 0 použije nativní rozlišení obrázku",
"imageResolution_optionTable": "tabulka",
"imageResolution_optionItemCard": "karta položky",
"imageResolution_optionSidebar": "postranní lišta",
"imageResolution_optionHeader": "záhlaví",
"imageResolution_optionFullScreenPlayer": "přehrávač na celé obrazovce",
"combinedLyricsAndVisualizer_description": "spojit texty a vizualizér do jednoho panelu",
"combinedLyricsAndVisualizer": "spojit texty a vizualizér v postranní liště přehrávače"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -385,7 +396,11 @@
"holdToMoveToTop": "podržte pro přesunutí nahoru",
"holdToMoveToBottom": "podržte pro přesunutí dolů",
"createRadioStation": "vytvořit $t(entity.radioStation_one)",
"deleteRadioStation": "odstranit $t(entity.radioStation_one)"
"deleteRadioStation": "odstranit $t(entity.radioStation_one)",
"openApplicationDirectory": "otevřít adresář aplikace",
"addOrRemoveFromSelection": "přidat nebo odebrat z výběru",
"selectRangeOfItems": "vyberte rozsah položek",
"selectAll": "vybrat vše"
},
"common": {
"backward": "zpátky",
@@ -502,7 +517,9 @@
"tableColumns": "sloupce tabulky",
"itemsMore": "{{count}} dalších",
"noFilters": "nejsou nastaveny žádné filtry",
"view": "zobrazit"
"view": "zobrazit",
"countSelected": "vybráno {{count}}",
"retry": "zkusit znovu"
},
"table": {
"config": {
@@ -634,7 +651,10 @@
"badValue": "neplatná možnost „{{value}}“. tato možnost již neexistuje",
"notificationDenied": "oprávnění k posílání oznámení byla zamítnuta. toto nastavení nemá žádný vliv",
"multipleServerSaveQueueError": "fronta přehrávání má jednu nebo více skladeb, které nejsou z aktuálního serveru. tato funkce není podporována",
"saveQueueFailed": "nepodařilo se uložit frontu"
"saveQueueFailed": "nepodařilo se uložit frontu",
"settingsSyncError": "byly zjištěny nesrovnalosti mezi nastavením v rendereru a hlavním procesem. restartujte aplikaci, aby se změny projevily",
"noNetwork": "server je nedostupný",
"noNetworkDescription": "k tomuto serveru se nepodařilo připojit"
},
"filter": {
"mostPlayed": "nejvíce přehráváno",
@@ -804,7 +824,8 @@
"transcoding": "překódování",
"discord": "discord",
"playerFilters": "filtry přehrávače",
"logger": "protokol"
"logger": "protokol",
"lyricsDisplay": "zobrazení textů"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -970,6 +991,11 @@
"input_homepageUrl": "adresa domovské stránky",
"input_name": "název",
"input_streamUrl": "adresa streamu"
},
"lyricsExport": {
"export": "exportovat texty",
"input_synced": "exportovat synchronizované texty",
"input_offset": "$t(setting.lyricOffset)"
}
},
"entity": {
@@ -1084,5 +1110,157 @@
"notInPlaylist": "není v",
"notInTheLast": "není v posledním",
"startsWith": "začíná na"
},
"datetime": {
"minuteShort": "min.",
"secondShort": "s",
"hourShort": "h.",
"dayShort": "den"
},
"visualizer": {
"visualizerType": "Typ vizualizéru",
"cyclePresets": "Cyklicky procházet předvolby",
"cycleTime": "Čas cyklování (sekundy)",
"includeAllPresets": "Zahrnout všechny předvolby",
"ignoredPresets": "Ignorované předvolby",
"selectedPresets": "Vybrané předvolby",
"randomizeNextPreset": "Náhodně vybrat další předvolbu",
"blendTime": "Prolnout čas",
"presets": "Předvolby",
"selectPreset": "Vybrat předvolbu",
"applyPreset": "Použít předvolbu",
"saveAsPreset": "Uložit jako předvolbu",
"updatePreset": "Aktualizovat předvolbu",
"copyConfiguration": "Kopírovat konfiguraci",
"pasteConfiguration": "Vložit konfiguraci",
"pasteConfigurationPlaceholder": "Sem vložte konfiguraci JSON…",
"pasteFromClipboard": "Vložit ze schránky",
"applyConfiguration": "Použít konfiguraci",
"configCopied": "Konfigurace zkopírována do schránky",
"configCopyFailed": "Nepodařilo se zkopírovat konfiguraci",
"configPasted": "Konfigurace úspěšně použita",
"configPasteFailed": "Nepodařilo se použít konfiguraci. Zkontrolujte prosím formát.",
"configPasteReadFailed": "Nepodařilo se přečíst schránku",
"presetName": "Název předvolby",
"presetNamePlaceholder": "Zadejte název předvolby",
"general": "Obecné",
"mode": "Režim",
"mode1To8": "Režim 18",
"mode10": "Režim 10",
"barSpace": "Mezera mezi sloupci",
"lineWidth": "Šířka linky",
"fillAlpha": "Vyplnit alfu",
"channelLayout": "Rozložení kanálů",
"maxFPS": "Max. počet snímků za sekundu",
"opacity": "Neprůhlednost",
"customGradients": "Vlastní přechody",
"addCustomGradient": "Přidat vlastní přechod",
"gradientName": "Název přechodu",
"gradientNamePlaceholder": "Název přechodu",
"vertical": "Vertikální",
"horizontal": "Horizontální",
"colorStops": "Ukončení barev",
"addColor": "Přidat barvu",
"position": "Pozice",
"level": "Úroveň",
"remove": "Odstranit",
"custom": "Vlastní",
"builtIn": "Vestavěné",
"colors": "Barvy",
"colorMode": "Režim barev",
"gradient": "Přechod",
"gradientLeft": "Přechod zleva",
"gradientRight": "Přechod zprava",
"fft": "FFT",
"fftSize": "Velikost FFT",
"smoothing": "Vyhlazování",
"frequencyRangeAndScaling": "Rozsah a škálování frekvencí",
"minimumFrequency": "Minimální frekvence",
"maximumFrequency": "Maximální frekvence",
"frequencyScale": "Škála frekvence",
"sensitivity": "Citlivost",
"weightingFilter": "Filtr váhy",
"minimumDecibels": "Minimální decibely",
"maximumDecibels": "Maximální decibely",
"linearAmplitude": "Lineární amplituda",
"linearBoost": "Lineární zesílení",
"peakBehavior": "Chování ve špičce",
"showPeaks": "Zobrazit špičky",
"fadePeaks": "Prolnout špičky",
"peakLine": "Linka špiček",
"gravity": "Gravitace",
"peakFadeTime": "Čas pádu ze špičky (ms)",
"peakHoldTime": "Čas udržení na špičce (ms)",
"radialSpectrum": "Kruhové spektrum",
"radial": "Kruhové",
"radialInvert": "Kruhové invertované",
"spinSpeed": "Rychlost rotace",
"radius": "Poloměr",
"reflexMirror": "Reflexní zrcadlení",
"reflexFit": "Reflexní vyplnění",
"reflexRatio": "Reflexní poměr",
"reflexAlpha": "Reflexní alfa",
"reflexBrightness": "Reflexní jas",
"mirror": "Zrcadlení",
"miscellaneousSettings": "Různá nastavení",
"alphaBars": "Alfa sloupce",
"ansiBands": "ANSI sloupce",
"ledBars": "LED sloupce",
"trueLeds": "Pravé LED",
"lumiBars": "Lumi sloupce",
"outlineBars": "Obrysové sloupce",
"roundBars": "Zaoblené sloupce",
"lowResolution": "Nízké rozlišení",
"splitGradient": "Přechod rozdělení",
"showFPS": "Zobrazit FPS",
"showScaleX": "Zobrazit osu X",
"noteLabels": "Štítky not",
"showScaleY": "Zobrazit osu Y",
"options": {
"mode": {
"bars": "[0] Sloupce",
"circle": "[1] Kruh",
"wave": "[2] Vlna",
"rainbow": "[3] Duha",
"rings": "[4] Prstence",
"mirror": "[5] Zrcadlo",
"line": "[6] Linka",
"particles": "[7] Částice",
"fullOctave": "[8] Plná oktáva / 10 pásem",
"outlineBars": "[10] Obrysové sloupce"
},
"colorMode": {
"gradient": "Přechod",
"barIndex": "Index sloupce",
"barLevel": "Úroveň sloupce"
},
"gradient": {
"classic": "Klasický",
"prism": "Prism",
"rainbow": "Duha",
"steelblue": "Ocelově modrá",
"orangered": "Oranžová"
},
"channelLayout": {
"single": "Jeden",
"dualCombined": "Duální kombinované",
"dualHorizontal": "Duální horizontální",
"dualVertical": "Duální vertikální"
},
"frequencyScale": {
"bark": "Bark",
"linear": "Lineární",
"log": "Log",
"mel": "Mel"
},
"weightingFilter": {
"none": "Žádný",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+112 -46
View File
@@ -1,7 +1,7 @@
{
"action": {
"editPlaylist": "$t(entity.playlist_one) bearbeiten",
"clearQueue": "Warteschlange leeren",
"clearQueue": "Wiedergabeliste leeren",
"addToFavorites": "Zu $t(entity.favorite_other) hinzufügen",
"addToPlaylist": "Zu $t(entity.playlist_one) hinzufügen",
"createPlaylist": "$t(entity.playlist_one) erstellen",
@@ -13,7 +13,7 @@
"removeFromPlaylist": "Aus $t(entity.playlist_one) entfernen",
"viewPlaylists": "$t(entity.playlist_other) anzeigen",
"refresh": "$t(common.refresh)",
"removeFromQueue": "Aus Warteschlange entfernen",
"removeFromQueue": "Aus Wiedergabeliste entfernen",
"setRating": "Bewerten",
"toggleSmartPlaylistEditor": "Editor für $t(entity.smartPlaylist) ein-/ausblenden",
"removeFromFavorites": "Aus $t(entity.favorite_other) entfernen",
@@ -29,7 +29,11 @@
"shuffleSelected": "Ausgewählte zufällig wiedergeben",
"viewMore": "Mehr zeigen",
"moveUp": "Nach oben bewegen",
"moveDown": "Nach unten bewegen"
"moveDown": "Nach unten bewegen",
"createRadioStation": "$t(entity.radioStation_one) erstellen",
"deleteRadioStation": "$t(entity.radioStation_one) löschen",
"selectAll": "alle auswählen",
"openApplicationDirectory": "Anwendungsverzeichnis öffnen"
},
"common": {
"backward": "zurück",
@@ -118,11 +122,11 @@
"close": "schließen",
"share": "Teilen",
"translation": "Übersetzung",
"trackGain": "Track-Pegelverstärkung",
"trackPeak": "Track-Spitzenpegel",
"trackGain": "Track Gain",
"trackPeak": "Track Peak",
"codec": "Codec",
"albumPeak": "Album-Spitzenpegel",
"albumGain": "Album-Pegelverstärkung",
"albumGain": "Album Gain",
"tags": "tags",
"viewReleaseNotes": "Veröffentlichungsnotizen anzeigen",
"newVersion": "eine neue Version wurde installiert ({{version}})",
@@ -145,7 +149,8 @@
"recordLabel": "Plattenlabel",
"slower": "langsamer",
"releaseType": "Veröffentlichungsformat",
"view": "Betrachten"
"view": "Betrachten",
"countSelected": "{{count}} ausgewählt"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -167,11 +172,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. Wahrscheinlich sehen Sie dieses Problem, wenn Sie einen Song in Ihrem Musikordner auf oberster Ebene haben. Jellyfin gruppiert nur Songs, wenn sie sich in einem Ordner 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 Ordners 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"
"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"
},
"filter": {
"mostPlayed": "Meistgespielt",
@@ -284,7 +291,7 @@
"setExpiration": "Ablaufdatum setzen",
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"allowDownloading": "Herunterladen zulassen",
"success": "Link in die Zwischenablage kopiert (oder hier klicken um zu öffnen)",
"success": "Link in die Zwischenablage kopiert (oder hier klicken, um zu öffnen)",
"createFailed": "fehler beim Teilen (Ist Teilen aktiviert?)"
},
"privateMode": {
@@ -293,7 +300,7 @@
"title": "Privatmodus"
},
"largeFetchConfirmation": {
"title": "Elemente der Warteschlange hinzufügen",
"title": "Elemente der Wiedergabeliste hinzufügen",
"description": "Diese Aktion fügt alle Elemente in der aktuell gefilterten Ansicht hinzu"
},
"shuffleAll": {
@@ -303,9 +310,19 @@
"input_minYear": "ab Jahr",
"input_maxYear": "bis Jahr",
"input_played_optionAll": "alle Tracks",
"input_played_optionUnplayed": "nur ungespielte Tracks",
"input_played_optionUnplayed": "nur nicht gespielte Tracks",
"input_played_optionPlayed": "nur gespielte Tracks",
"input_played": "Wiedergabefilter"
},
"saveQueue": {
"success": "Wiedergabeliste auf Server gespeichert"
},
"createRadioStation": {
"success": "Radiosender erfolgreich erstellt",
"title": "Radiosender erstellen",
"input_homepageUrl": "Homepage URL",
"input_name": "Name",
"input_streamUrl": "Stream URL"
}
},
"entity": {
@@ -343,7 +360,11 @@
"play_one": "{{count}} Wiedergabe",
"play_other": "{{count}} Wiedergaben",
"song_one": "Lied",
"song_other": "Lieder"
"song_other": "Lieder",
"radioStation_one": "Radiosender",
"radioStation_other": "Radiosender",
"radioStationWithCount_one": "{{count}} Radiosender",
"radioStationWithCount_other": "{{count}} Radiosender"
},
"table": {
"config": {
@@ -359,7 +380,17 @@
"displayType": "Anzeigestil",
"autoFitColumns": "automatisch Spalten einpassen",
"size_default": "Standard",
"followCurrentSong": "aktuellem Titel folgen"
"followCurrentSong": "aktuellem Titel folgen",
"advancedSettings": "erweiterte Einstellungen",
"autosize": "automatische Größe",
"alignLeft": "linksbündig",
"alignCenter": "mittig",
"alignRight": "rechtsbündig",
"size_compact": "kompakt",
"size_large": "groß",
"pagination": "Seitenzahlen",
"pagination_itemsPerPage": "Elemente pro Seite",
"pagination_infinite": "unendlich"
},
"label": {
"dateAdded": "Hinzugefügt am",
@@ -387,7 +418,14 @@
"title": "$t(common.title)",
"year": "$t(common.year)",
"discNumber": "disk-Nummer",
"playCount": "Wiedergaben"
"playCount": "Wiedergaben",
"albumCount": "$t(entity.album_other)",
"bitDepth": "$t(common.bitDepth)",
"codec": "$t(common.codec)",
"image": "Bild",
"sampleRate": "$t(common.sampleRate)",
"songCount": "$t(entity.track_other)",
"genreBadge": "$t(entity.genre_one) (Abzeichen)"
}
},
"column": {
@@ -413,7 +451,11 @@
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
"trackNumber": "titel",
"size": "$t(common.size)"
"size": "$t(common.size)",
"bitDepth": "$t(common.bitDepth)",
"codec": "$t(common.codec)",
"sampleRate": "$t(common.sampleRate)",
"owner": "Besitzer"
}
},
"page": {
@@ -522,7 +564,8 @@
"albumArtists": "$t(entity.albumArtist_other)",
"shared": "$t(entity.playlist_other) geteilt",
"myLibrary": "meine bibliothek",
"favorites": "$t(entity.favorite_other)"
"favorites": "$t(entity.favorite_other)",
"radio": "$t(entity.radioStation_other)"
},
"setting": {
"playbackTab": "Wiedergabe",
@@ -538,7 +581,7 @@
"application": "App",
"queryBuilder": "Abfrage-Editor",
"theme": "Erscheinungsbild",
"controls": "Steuerung",
"controls": "Steuerelemente",
"sidebar": "Seitenleiste",
"scrobble": "Scrobbeln",
"audio": "Audio",
@@ -601,14 +644,17 @@
},
"playlist": {
"reorder": "Neuanordnung nur bei Sortierung nach ID möglich"
},
"radioList": {
"title": "Radiosender"
}
},
"player": {
"next": "nächster",
"addNext": "Als Nächstes spielen",
"addNext": "als Nächstes",
"play": "Abspielen",
"muted": "stummgeschaltet",
"addLast": "Als Letztes spielen",
"addLast": "als Letztes",
"mute": "Stumm",
"playRandom": "Zufällige Wiedergabe",
"previous": "Vorheriger",
@@ -617,7 +663,7 @@
"playbackFetchInProgress": "lieder werden geladen…",
"playbackSpeed": "Wiedergabegeschwindigkeit",
"playbackFetchCancel": "Das dauert eine Weile. Schließen Sie die Benachrichtigung, um den Vorgang abzubrechen",
"queue_clear": "Bereinige Warteschlange",
"queue_clear": "Wiedergabeliste bereinigen",
"repeat_all": "Alle wiederholen",
"repeat": "Wiederholen",
"queue_remove": "Ausgewählte entfernen",
@@ -634,13 +680,15 @@
"skip_forward": "vorspulen",
"skip": "Überspringen",
"playSimilarSongs": "Ähnliche Lieder abspielen",
"viewQueue": "Warteschlange anzeigen",
"addLastShuffled": "Als Letztes spielen (zufällige Wiedergabe)",
"addNextShuffled": "Als Nächstes spielen (zufällige Wiedergabe)",
"viewQueue": "Wiedergabeliste anzeigen",
"addLastShuffled": "als Letztes (zufällige Wiedergabe)",
"addNextShuffled": "als Nächstes (zufällige Wiedergabe)",
"queueType_default": "Standard",
"queueType_priority": "Priorität",
"holdToShuffle": "Halten für Zufallswiedergabe",
"queueType": "Warteschlangentyp"
"queueType": "Wiedergabelistentyp",
"restoreQueueFromServer": "Wiedergabeliste von Server wiederherstellen",
"saveQueueToServer": "Wiedergabeliste auf Server speichern"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
@@ -716,7 +764,7 @@
"themeLight_description": "Legt das Erscheinungsbild für den hellen Modus fest",
"hotkey_toggleFullScreenPlayer": "Vollbildmodus umschalten",
"hotkey_localSearch": "Suche auf Seite",
"hotkey_toggleQueue": "Warteschlange umschalten",
"hotkey_toggleQueue": "Wiedergabeliste umschalten",
"remotePassword_description": "Legt das Passwort für den Fernsteuerungsserver fest. Diese Anmeldeinformationen werden standardmäßig unsicher übertragen, daher sollten Sie ein Passwort verwenden, das Ihnen egal ist",
"hotkey_rate5": "Bewertung 5 Sterne",
"hotkey_playbackPrevious": "Vorheriger Track",
@@ -727,18 +775,18 @@
"playbackStyle_description": "Wählen Sie den Wiedergabestil aus, der für den Audioplayer verwendet werden soll",
"mpvExecutablePath": "Pfad der ausführbaren MPV-Datei",
"hotkey_rate2": "Bewertung 2 Sterne",
"playButtonBehavior_description": "Legt das Standardverhalten des Wiedergabe-Buttons fest, wenn Songs zur Warteschlange hinzugefügt werden",
"playButtonBehavior_description": "legt das Standardverhalten des Wiedergabe-Buttons fest, wenn Lieder zur Wiedergabeliste hinzugefügt werden",
"minimumScrobblePercentage_description": "die Mindestdauer in Prozent, welche das Lied gespielt werden muss, bevor dieses gescrobbelt wird",
"hotkey_rate4": "Bewertung 4 Sterne",
"showSkipButton_description": "Ein- oder Ausblenden der Überspringen-Schaltflächen in der Player-Leiste",
"savePlayQueue": "Wiedergabe-Warteschlange speichern",
"savePlayQueue": "Wiedergabeliste speichern",
"minimumScrobbleSeconds_description": "die Mindestdauer in Sekunden, welche das Lied gespielt werden muss, bevor dieses gescrobbelt wird",
"skipPlaylistPage_description": "Gehe beim Navigieren zu einer Wiedergabeliste zu deren Titelseite und nicht zur Standardseite",
"fontType_description": "Die integrierte Schriftart wählt eine der von feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitzustellen",
"playButtonBehavior": "Verhalten der Wiedergabetaste",
"volumeWheelStep": "Lautstärkeänderung mit Mausrad",
"sidebarPlaylistList_description": "Ein- oder Ausblenden der Playlisten-Liste in der Seitenleiste",
"sidePlayQueueStyle_description": "Legt den Stil der Wiedergabewarteliste in der Seitenleiste fest",
"sidePlayQueueStyle_description": "legt den Stil der Wiedergabeliste in der Seitenleiste fest",
"replayGainMode": "{{ReplayGain}} Modus",
"playbackStyle_optionNormal": "Normal",
"windowBarStyle": "Fensterleistenstil",
@@ -769,7 +817,7 @@
"gaplessAudio_optionWeak": "schwach (empfohlen)",
"minimumScrobbleSeconds": "Minimum Scrobble-Dauer (Sekunden)",
"hotkey_playbackStop": "Stoppen",
"savePlayQueue_description": "Speichert Wiedergabewarteschlange, wenn die Anwendung geschlossen wird, und stellt sie wieder her, wenn die Anwendung geöffnet wird",
"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",
"enableRemote_description": "Aktiviert den Server für die Fernsteuerung, damit andere Geräte die Anwendung steuern können",
"fontType_optionSystem": "System Schriftart",
@@ -795,33 +843,33 @@
"clearCache": "Browser-Zwischenspeicher löschen",
"clearQueryCache": "feishins Zwischenspeicher leeren",
"clearCache_description": "Hartes Zurücksetzen. Neben feishins Zwischenspeicher wird auch der des Browsers gelöscht (Bilder und andere Daten). Zugangsinformationen und Einstellungen werden behalten",
"sidePlayQueueStyle": "Wiedergabelistenstil in der Seitenleiste",
"sidePlayQueueStyle": "Stil der Wiedergabeliste in der Seitenleiste",
"zoom_description": "Setzt den Zoom (in %) für das Programm",
"zoom": "Zoom",
"albumBackground": "Album Hintergrund",
"customCss": "Benutzerdefiniert css",
"customCss": "Benutzerdefiniertes CSS",
"homeConfiguration": "Startseite Konfiguration",
"lastfmApiKey": "{{lastfm}} API-Schlüssel",
"lastfmApiKey_description": "Der API-Schlüssel für {{lastfm}}. wird für benötigt",
"lastfmApiKey_description": "Der API-Schlüssel für {{lastfm}}. wird für Albumcover benötigt",
"discordListening": "Status als hört zu anzeigen",
"discordListening_description": "Status als hört zu statt als spielt anzeigen",
"lastfm": "zeige last.fm links",
"lastfm_description": "zeige links zu Last.fm auf dem Künstler/Album-Seiten",
"musicbrainz": "Zeig MusicBrainz links",
"customCssEnable": "aktiviere Benutzerdefinierte css",
"customCssEnable": "benutzerdefiniertes CSS aktivieren",
"albumBackground_description": "fügt ein Hintergrundbild für die Albumseiten hinzu, welche das Albumcover zeigen",
"albumBackgroundBlur": "Größe der Album-Bildunschärfe",
"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": "ermöglicht das Schreiben benutzerdefinierten 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",
"buttonSize": "spielerleisten-Knopfgröße",
"buttonSize_description": "die Größe der Spieler-Knöpfe",
"hotkey_togglePreviousSongFavorite": "wähle $t(common.previousSong) als Favorit aus",
"replayGainFallback": "{{ReplayGain}} Rückgriff",
"replayGainFallback": "{{ReplayGain}} Alternative",
"replayGainClipping": "{{ReplayGain}} Clipping",
"exportImportSettings_control_description": "Einstellungen mit JSON exportieren und importieren",
"exportImportSettings_control_exportText": "Einstellungen exportieren",
@@ -833,9 +881,7 @@
"exportImportSettings_notValidJSON": "Die Datei ist kein gültiges JSON",
"exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" ist falsch - {{reason}}",
"language": "Sprache",
"playerAlbumArtResolution": "Auflösung des Albumcovers",
"imageAspectRatio": "Original Seitenverhältnis des Albumcovers verwenden",
"playerAlbumArtResolution_description": "die Auflösung des Albumcovers im großen Player. Eine höhere Auflösung sorgt für ein schärferes Bild, kann jedoch das Laden verlangsamen. Standardwert: 0 (automatische Berechnung)",
"analyticsDisable": "Keine nutzungsbasierte Analyse",
"analyticsDisable_description": "Anonymisierte Nutzungsdaten werden an den Entwickler geschickt, um die Anwendung zu verbessern",
"logLevel_optionDebug": "Debug",
@@ -844,11 +890,11 @@
"logLevel_optionError": "Fehler",
"logLevel_optionInfo": "Info",
"logLevel_optionWarn": "Warnung",
"autoDJ_description": "Füge automatisch ähnliche Lieder der Warteschlange hinzu",
"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 Warteschlange hinzugefügt werden sollen",
"autoDJ_timing_description": "Die Anzahl der Lieder, die sich noch in der Warteschlange befinden, bevor Auto DJ ausgelöst wird",
"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_timing": "Timing",
"discordDisplayType": "{{discord}} Presence Darstellungsart",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} mit {{lastfm}} als Ersatz",
@@ -867,11 +913,11 @@
"neteaseTranslation": "NetEase Übersetzungen aktivieren",
"notify": "Benachrichtigungen aktivieren",
"notify_description": "Zeigt Benachrichtigungen beim Titelwechsel",
"playerFilters": "Lieder in der Warteschlange filtern",
"playerFilters": "Lieder der Wiedergabeliste filtern",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"volumeWidth_description": "Die Breite des Lautstärkereglers",
"volumeWidth": "Lautstärkereglerbreite",
"webAudio_description": "Web-Audio verwenden. Dies ermöglicht erweiterte Funktionen wie ReplayGain. Deaktiviere dies, wenn bei der Wiedergabe Probleme auftreten",
"webAudio_description": "Web-Audio verwenden. Dies ermöglicht erweiterte Funktionen wie ReplayGain. Deaktiviere die Option, falls bei der Wiedergabe Probleme auftreten",
"webAudio": "Web-Audio verwenden",
"trayEnabled": "Info-Symbol anzeigen",
"transcode": "Transkodierung aktivieren",
@@ -889,7 +935,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: content und entfernte URLs sind unzulässig. Siehe Vorschau unten. Aufgrund von Bereinigung werden womöglich nicht gesetzte Felder angezeigt",
"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",
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (url() und content: sind nicht zulässig), kann die Verwendung von benutzerdefiniertem CSS dennoch Risiken mit sich bringen, da dadurch die Benutzeroberfläche verändert wird",
"releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Stabil",
@@ -903,11 +949,25 @@
"exportImportSettings_destructiveWarning": "Das Importieren von Einstellungen ist irreversibel. Bitte lies die Hinweise oben sorgfältig durch, bevor du auf \"Importieren\" klickst!",
"followCurrentSong": "aktuellem Titel folgen",
"followCurrentSong_description": "die Wiedergabeliste scrollt automatisch zum aktuellen Titel",
"playerFilters_description": "verhindert, dass Titel anhand der folgenden Kriterien zur Warteschlange hinzugefügt werden",
"playerFilters_description": "verhindert, dass Titel anhand der folgenden Kriterien zur Wiedergabeliste hinzugefügt werden",
"preferLocalLyrics_description": "lokale Songtexte gegenüber externen Quellen bevorzugen (sofern verfügbar)",
"preferLocalLyrics": "Priorisiere lokale Songtexte",
"showLyricsInSidebar_description": "ein Bereich, in dem Songtexte angezeigt werden, wird der Wiedergabeliste hinzugefügt",
"showLyricsInSidebar": "zeige Songtexte in der Player-Seitenleiste"
"showLyricsInSidebar": "zeige Songtexte in der Player-Seitenleiste",
"homeFeature_description": "steuert, ob das große Featured-Karussell auf der Startseite angezeigt wird",
"homeFeature": "Feature-Karussell",
"playerbarWaveformAlign_optionTop": "Oben",
"playerbarWaveformAlign_optionCenter": "Mitte",
"playerbarWaveformAlign_optionBottom": "Unten",
"translationApiKey_description": "API-Schlüssel für Übersetzungen (nur globale Service-Endpunkte)",
"translationApiKey": "API-Schlüssel für Übersetzungen",
"translationApiProvider_description": "API-Anbieter für Übersetzungen",
"translationApiProvider": "API-Anbieter für Übersetzungen",
"hotkey_navigateHome": "zurück zur Startseite",
"translationTargetLanguage_description": "die gewünschte Sprache der Übersetzung",
"translationTargetLanguage": "Zielsprache der Übersetzung",
"queryBuilderCustomFields": "benutzerdefiniertes Feld",
"queryBuilderCustomFields_inputTag": "Tag"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
@@ -961,5 +1021,11 @@
"soundtrack": "Soundtrack",
"spokenWord": "Gesprochenes Wort"
}
},
"datetime": {
"minuteShort": "Min",
"secondShort": "Sek",
"hourShort": "Std",
"dayShort": "Tag"
}
}
+183 -2
View File
@@ -2,11 +2,14 @@
"action": {
"addToFavorites": "add to $t(entity.favorite_other)",
"addToPlaylist": "add to $t(entity.playlist_one)",
"addOrRemoveFromSelection": "add or remove from selection",
"selectRangeOfItems": "select a range of items",
"clearQueue": "clear queue",
"createPlaylist": "create $t(entity.playlist_one)",
"createRadioStation": "create $t(entity.radioStation_one)",
"deletePlaylist": "delete $t(entity.playlist_one)",
"deleteRadioStation": "delete $t(entity.radioStation_one)",
"selectAll": "select all",
"deselectAll": "deselect all",
"downloadStarted": "started download of {{count}} items",
"editPlaylist": "edit $t(entity.playlist_one)",
@@ -30,12 +33,14 @@
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)",
"viewMore": "view more",
"openApplicationDirectory": "open application directory",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
}
},
"common": {
"countSelected": "{{count}} selected",
"explicitStatus": "explicit status",
"action_one": "action",
"action_other": "actions",
@@ -114,6 +119,7 @@
"quit": "quit",
"random": "random",
"rating": "rating",
"retry": "retry",
"recordLabel": "record label",
"releaseType": "release type",
"refresh": "refresh",
@@ -207,6 +213,8 @@
"mpvRequired": "MPV required",
"multipleServerSaveQueueError": "the play queue has one or more songs which are not from the current server. this is not supported",
"networkError": "a network error occurred",
"noNetwork": "server unavailable",
"noNetworkDescription": "couldn't connect to this server",
"notificationDenied": "permissions for notifications were denied. this setting has no effect",
"openError": "could not open file",
"playbackError": "an error occurred when trying to play the media",
@@ -266,6 +274,12 @@
"trackNumber": "track",
"explicitStatus": "$t(common.explicitStatus)"
},
"datetime": {
"minuteShort": "m",
"secondShort": "s",
"hourShort": "h",
"dayShort": "d"
},
"filterOperator": {
"after": "is after",
"afterDate": "is after (date)",
@@ -341,6 +355,11 @@
"success": "$t(entity.playlist_one) updated successfully",
"title": "edit $t(entity.playlist_one)"
},
"lyricsExport": {
"export": "export lyrics",
"input_synced": "export synced lyrics",
"input_offset": "$t(setting.lyricOffset)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
@@ -391,6 +410,8 @@
"albumArtistDetail": {
"about": "About {{artist}}",
"appearsOn": "appears on",
"groupingTypeAll": "all release types",
"groupingTypePrimary": "primary release types",
"recentReleases": "recent releases",
"viewDiscography": "view discography",
"relatedArtists": "related $t(entity.artist_other)",
@@ -550,6 +571,7 @@
"scrobble": "scrobble",
"audio": "audio",
"lyrics": "lyrics",
"lyricsDisplay": "lyrics display",
"transcoding": "transcoding",
"discord": "discord",
"logger": "logger",
@@ -583,6 +605,7 @@
"addNext": "next",
"addLastShuffled": "last (shuffled)",
"addNextShuffled": "next (shuffled)",
"artistRadio": "artist radio",
"holdToShuffle": "hold to shuffle",
"favorite": "favorite",
"lyrics": "lyrics",
@@ -618,6 +641,7 @@
"skip_forward": "skip forwards",
"stop": "stop",
"toggleFullscreenPlayer": "toggle fullscreen player",
"trackRadio": "track radio",
"unfavorite": "unfavorite",
"pause": "pause",
"viewQueue": "view queue"
@@ -849,8 +873,15 @@
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior": "play button behavior",
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
"playerAlbumArtResolution": "player album art resolution",
"artistRadioCount_description": "sets the number of songs to fetch for artist radio and track radio",
"artistRadioCount": "artist/track radio count",
"imageResolution": "image resolution",
"imageResolution_description": "the resolution for the images used around the app. using a value of 0 will default to the native image resolution",
"imageResolution_optionTable": "table",
"imageResolution_optionItemCard": "item card",
"imageResolution_optionSidebar": "sidebar",
"imageResolution_optionHeader": "header",
"imageResolution_optionFullScreenPlayer": "fullscreen player",
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
"playerbarOpenDrawer": "playerbar fullscreen toggle",
"playerbarSlider": "playerbar slider",
@@ -868,8 +899,12 @@
"preferLocalLyrics": "prefer local lyrics",
"showLyricsInSidebar_description": "a panel will be added to the attached play queue that displays the lyrics",
"showLyricsInSidebar": "show lyrics in player sidebar",
"showRatings_description": "controls if the star ratings feature shows up in the interface",
"showRatings": "show star ratings",
"showVisualizerInSidebar_description": "a panel will be added to the player sidebar that displays the visualizer",
"showVisualizerInSidebar": "show visualizer in player sidebar",
"combinedLyricsAndVisualizer_description": "combine lyrics and visualizer into the same panel",
"combinedLyricsAndVisualizer": "combine lyrics and visualizer in player sidebar",
"preservePitch_description": "preserves pitch when modifying playback speed",
"preservePitch": "preserve pitch",
"audioFadeOnStatusChange": "audio fade on status change",
@@ -1067,5 +1102,151 @@
"error_oneFileOnly": "Please only select 1 file",
"error_readingFile": "there has been an issue reading the file: {{errorMessage}}",
"mainText": "drop a file here"
},
"visualizer": {
"visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)",
"includeAllPresets": "Include All Presets",
"ignoredPresets": "Ignored Presets",
"selectedPresets": "Selected Presets",
"randomizeNextPreset": "Randomize Next Preset",
"blendTime": "Blend Time",
"presets": "Presets",
"selectPreset": "Select Preset",
"applyPreset": "Apply Preset",
"saveAsPreset": "Save as Preset",
"updatePreset": "Update Preset",
"copyConfiguration": "Copy Configuration",
"pasteConfiguration": "Paste Configuration",
"pasteConfigurationPlaceholder": "Paste JSON configuration here...",
"pasteFromClipboard": "Paste from Clipboard",
"applyConfiguration": "Apply Configuration",
"configCopied": "Configuration copied to clipboard",
"configCopyFailed": "Failed to copy configuration",
"configPasted": "Configuration applied successfully",
"configPasteFailed": "Failed to apply configuration. Please check the format.",
"configPasteReadFailed": "Failed to read from clipboard",
"presetName": "Preset Name",
"presetNamePlaceholder": "Enter preset name",
"general": "General",
"mode": "Mode",
"mode1To8": "Mode 1 - 8",
"mode10": "Mode 10",
"barSpace": "Bar Space",
"lineWidth": "Line Width",
"fillAlpha": "Fill Alpha",
"channelLayout": "Channel Layout",
"maxFPS": "Max FPS",
"opacity": "Opacity",
"customGradients": "Custom Gradients",
"addCustomGradient": "Add Custom Gradient",
"gradientName": "Gradient Name",
"gradientNamePlaceholder": "Gradient Name",
"vertical": "Vertical",
"horizontal": "Horizontal",
"colorStops": "Color Stops",
"addColor": "Add Color",
"position": "Position",
"level": "Level",
"remove": "Remove",
"custom": "Custom",
"builtIn": "Built-in",
"colors": "Colors",
"colorMode": "Color Mode",
"gradient": "Gradient",
"gradientLeft": "Gradient Left",
"gradientRight": "Gradient Right",
"fft": "FFT",
"fftSize": "FFT Size",
"smoothing": "Smoothing",
"frequencyRangeAndScaling": "Frequency range and scaling",
"minimumFrequency": "Minimum Frequency",
"maximumFrequency": "Maximum Frequency",
"frequencyScale": "Frequency Scale",
"sensitivity": "Sensitivity",
"weightingFilter": "Weighting Filter",
"minimumDecibels": "Minimum Decibels",
"maximumDecibels": "Maximum Decibels",
"linearAmplitude": "Linear Amplitude",
"linearBoost": "Linear Boost",
"peakBehavior": "Peak Behavior",
"showPeaks": "Show Peaks",
"fadePeaks": "Fade Peaks",
"peakLine": "Peak Line",
"gravity": "Gravity",
"peakFadeTime": "Peak Fade Time (ms)",
"peakHoldTime": "Peak Hold Time (ms)",
"radialSpectrum": "Radial Spectrum",
"radial": "Radial",
"radialInvert": "Radial Invert",
"spinSpeed": "Spin Speed",
"radius": "Radius",
"reflexMirror": "Reflex Mirror",
"reflexFit": "Reflex Fit",
"reflexRatio": "Reflex Ratio",
"reflexAlpha": "Reflex Alpha",
"reflexBrightness": "Reflex Brightness",
"mirror": "Mirror",
"miscellaneousSettings": "Miscellaneous Settings",
"alphaBars": "Alpha Bars",
"ansiBands": "ANSI Bands",
"ledBars": "LED Bars",
"trueLeds": "True LEDs",
"lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars",
"roundBars": "Round Bars",
"lowResolution": "Low Resolution",
"splitGradient": "Split Gradient",
"showFPS": "Show FPS",
"showScaleX": "Show Scale X",
"noteLabels": "Note Labels",
"showScaleY": "Show Scale Y",
"options": {
"mode": {
"bars": "[0] Bars",
"circle": "[1] Circle",
"wave": "[2] Wave",
"rainbow": "[3] Rainbow",
"rings": "[4] Rings",
"mirror": "[5] Mirror",
"line": "[6] Line",
"particles": "[7] Particles",
"fullOctave": "[8] Full octave / 10 bands",
"outlineBars": "[10] Outline bars"
},
"colorMode": {
"gradient": "Gradient",
"barIndex": "Bar-Index",
"barLevel": "Bar-Level"
},
"gradient": {
"classic": "Classic",
"prism": "Prism",
"rainbow": "Rainbow",
"steelblue": "Steelblue",
"orangered": "Orangered"
},
"channelLayout": {
"single": "Single",
"dualCombined": "Dual-Combined",
"dualHorizontal": "Dual-Horizontal",
"dualVertical": "Dual-Vertical"
},
"frequencyScale": {
"bark": "Bark",
"linear": "Linear",
"log": "Log",
"mel": "Mel"
},
"weightingFilter": {
"none": "None",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+33 -8
View File
@@ -39,7 +39,9 @@
"holdToShuffle": "Mantener para mezclar",
"lyrics": "Letras",
"restoreQueueFromServer": "Restaurar cola del servidor",
"saveQueueToServer": "Guardar cola en el servidor"
"saveQueueToServer": "Guardar cola en el servidor",
"artistRadio": "Radio de artista",
"trackRadio": "Radio de pista"
},
"setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -206,8 +208,6 @@
"startMinimized_description": "inicia la aplicación en la bandeja del sistema",
"startMinimized": "iniciar minimizado",
"passwordStore": "contraseñas/almacenamiento secreto",
"playerAlbumArtResolution_description": "la resolución para la vista previa de la carátula del álbum del reproductor grande. más grande hace que parezca más nítido, pero puede ralentizar la carga. El valor predeterminado es 0, lo que significa automático",
"playerAlbumArtResolution": "resolución de la carátula del álbum del reproductor",
"homeConfiguration": "Configuración de la página de inicio",
"mpvExtraParameters_help": "Uno por línea",
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas del artista/álbum",
@@ -349,7 +349,11 @@
"logLevel_optionInfo": "Información",
"logLevel_optionWarn": "Advertencia",
"useThemeAccentColor": "Usar color de acentuación de tema",
"useThemeAccentColor_description": "Usa el color principal definido en el tema seleccionado en lugar del color de acentuación personalizado"
"useThemeAccentColor_description": "Usa el color principal definido en el tema seleccionado en lugar del color de acentuación personalizado",
"artistRadioCount_description": "Establece el número de canciones a buscar para la radio de artista y de pista",
"artistRadioCount": "Recuento de radio de artista/pista",
"imageResolution": "Resolución de imagen",
"imageResolution_description": "La resolución de las imágenes usadas en la aplicación. Usar un valor de 0 lo dejará de forma predeterminada a la resolución nativa de la imagen"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -385,7 +389,11 @@
"moveUp": "Desplazar hacia arriba",
"moveDown": "Desplazar hacia abajo",
"createRadioStation": "Crear $t(entity.radioStation_one)",
"deleteRadioStation": "Borrar $t(entity.radioStation_one)"
"deleteRadioStation": "Borrar $t(entity.radioStation_one)",
"openApplicationDirectory": "Abrir directorio de la aplicación",
"addOrRemoveFromSelection": "Añadir o quitar de la selección",
"selectRangeOfItems": "Seleccionar un intervalo de elementos",
"selectAll": "Seleccionar todo"
},
"common": {
"backward": "hacia atrás",
@@ -502,7 +510,9 @@
"tableColumns": "Columnas de la tabla",
"itemsMore": "{{count}} más",
"noFilters": "Ningún filtro configurado",
"view": "Vista"
"view": "Vista",
"countSelected": "{{count}} seleccionados",
"retry": "Reintentar"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -530,7 +540,10 @@
"badValue": "Opción inválida \"{{value}}\". Este valor ya no existe",
"notificationDenied": "Se denegaron los permisos para notificaciones. Esta configuración no tiene efecto",
"saveQueueFailed": "Error al guardar la cola",
"multipleServerSaveQueueError": "La cola de reproducción tiene una o más canciones que no son del servidor actual. Esto no está soportado"
"multipleServerSaveQueueError": "La cola de reproducción tiene una o más canciones que no son del servidor actual. Esto no está soportado",
"settingsSyncError": "Se encontraron discrepancias entre las opciones del renderizador y el proceso principal. Reinicia la aplicación para aplicar los cambios",
"noNetwork": "Servidor no disponible",
"noNetworkDescription": "No se pudo conectar a este servidor"
},
"filter": {
"mostPlayed": "más reproducido",
@@ -700,7 +713,8 @@
"discord": "Discord",
"sidebar": "Barra lateral",
"playerFilters": "Filtros del reproductor",
"logger": "Registrador"
"logger": "Registrador",
"lyricsDisplay": "Mostrar letras"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -866,6 +880,11 @@
"input_homepageUrl": "URL de la página de inicio",
"input_name": "Nombre",
"input_streamUrl": "URL de la transmisión"
},
"lyricsExport": {
"export": "Exportar letras",
"input_synced": "Exportar letras sincronizadas",
"input_offset": "$t(setting.lyricOffset)"
}
},
"table": {
@@ -1084,5 +1103,11 @@
"notInTheLast": "no está en el último",
"startsWith": "empieza con",
"matchesRegex": "coincide con expresión regular"
},
"datetime": {
"minuteShort": "min",
"secondShort": "seg",
"hourShort": "h",
"dayShort": "día"
}
}
-1
View File
@@ -519,7 +519,6 @@
"playbackStyle_description": "aukeratu audio erreproduzitzailearentzat erabiliko den erreprodukzio estiloa",
"playButtonBehavior": "erreprodukzio botoiaren portaera",
"playButtonBehavior_description": "ezartzen du erreprodukzio botoiaren portaera lehenetsia abestiak ilaran gehitzean",
"playerAlbumArtResolution": "erreproduzitzailearen albumaren arte-azalaren erresoluzioa",
"gaplessAudio": "hutsune gabeko audioa",
"gaplessAudio_description": "ezartzen du hutsunik gabeko audio ezarpena mpv-rako",
"passwordStore": "pasahitzak/biltegi sekretua",
-2
View File
@@ -474,7 +474,6 @@
"replayGainClipping": "{{ReplayGain}} leikkaus",
"replayGainClipping_description": "Estää {{ReplayGain}}n aiheuttaman leikkauksen laskemalla vahvistusta automaatisesti",
"replayGainFallback": "{{ReplayGain}} palautus",
"playerAlbumArtResolution_description": "suurien kansikuvien resoluutio soittimen esikatselussa. suurempi tekee niistä terävempiä, mutta voi hidastaa latausta. oletuksena on 0, joka tarkoittaa automaattista",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainPreamp": "{{ReplayGain}} esivahvistus (dB)",
"scrobble_description": "skrobblaa toistot mediapalvelimellesi",
@@ -490,7 +489,6 @@
"sidebarConfiguration": "sivupalkin asetukset",
"sidebarConfiguration_description": "valitse kohteet ja niiden järjestys sivupalkissa",
"volumeWidth_description": "äänenvoimakkuuden säätimen leveys",
"playerAlbumArtResolution": "soittimen kansikuvien resoluutio",
"playerbarOpenDrawer": "toistipalkin kokoruudun kytkin",
"playerbarOpenDrawer_description": "sallii toistopalkin klikkaamisen avaamaan kokonäytön soittimen",
"replayGainFallback_description": "asetettava vahvistus desibelinä (dB), jos tiedostolla ei ole {{ReplayGain}} tageja",
+10 -11
View File
@@ -14,7 +14,7 @@
"shuffle": "lecture (mélangé)",
"playbackFetchNoResults": "aucun titre trouvé",
"playbackFetchInProgress": "chargement des titres…",
"addNext": "ajouter ensuite",
"addNext": "prochain",
"playbackSpeed": "vitesse de lecture",
"playbackFetchCancel": "cela prend du temps… fermez la notification pour annuler",
"play": "lecture",
@@ -24,15 +24,15 @@
"queue_moveToTop": "déplacer la sélection vers le bas",
"queue_moveToBottom": "déplacer la sélection vers le haut",
"shuffle_off": "aléatoire désactivée",
"addLast": "ajouter en dernier",
"addLast": "dernier",
"mute": "muet",
"skip_forward": "avancer",
"pause": "pause",
"unfavorite": "retirer des favoris",
"playSimilarSongs": "jouer des titres similaires",
"viewQueue": "voir la file d'attente",
"addLastShuffled": "ajouter en dernier (mélangé)",
"addNextShuffled": "ajouter ensuite (mélangé)",
"addLastShuffled": "dernier (mélangé)",
"addNextShuffled": "prochain (mélangé)",
"queueType": "type de file d'attente",
"queueType_default": "défaut",
"queueType_priority": "priorité",
@@ -223,7 +223,8 @@
"badValue": "option {{value}} invalide. Cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
"multipleServerSaveQueueError": "la file d'attente de lecture contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"saveQueueFailed": "échec de l'enregistrement de la file d'attente"
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications"
},
"filter": {
"mostPlayed": "plus joués",
@@ -633,9 +634,7 @@
"imageAspectRatio_description": "si cette option est activée, les pochettes d'album seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
"mpvExtraParameters_help": "un par ligne",
"passwordStore_description": "quel mot de passe utiliser. changez cela si vous rencontrez des problèmes pour stocker les mots de passe",
"playerAlbumArtResolution": "résolution de la pochette d'album du lecteur",
"passwordStore": "mots de passe",
"playerAlbumArtResolution_description": "résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
"startMinimized": "démarrer l'application en mode réduit",
"transcode_description": "permet le transcodage vers différents formats",
@@ -925,11 +924,11 @@
"song_many": "titres",
"song_other": "titres",
"radioStation_one": "station radio",
"radioStation_many": "stations radios",
"radioStation_other": "",
"radioStation_many": "stations radio",
"radioStation_other": "stations radio",
"radioStationWithCount_one": "{{count}} station radio",
"radioStationWithCount_many": "{{count}} stations radios",
"radioStationWithCount_other": ""
"radioStationWithCount_many": "{{count}} stations radio",
"radioStationWithCount_other": "{{count}} stations radio"
},
"table": {
"config": {
+50 -13
View File
@@ -31,7 +31,13 @@
"moveUp": "ugrás fel",
"moveDown": "ugrás le",
"holdToMoveToTop": "hosszan nyomva felülre mozgat",
"holdToMoveToBottom": "hosszan nyomva lejjebb mozgat"
"holdToMoveToBottom": "hosszan nyomva lejjebb mozgat",
"selectAll": "összes kijelölése",
"deleteRadioStation": "$t(entity.radioStation_one) törlése",
"createRadioStation": "$t(entity.radioStation_one) létrehozása",
"openApplicationDirectory": "app könyvtár megnyitása",
"addOrRemoveFromSelection": "hozzáadás vagy eltávolítás a kiválasztásból",
"selectRangeOfItems": "válaszd ki a tartományt"
},
"common": {
"collapse": "összecsukás",
@@ -145,7 +151,9 @@
"tableColumns": "táblázat oszlopok",
"itemsMore": "{{count}} még több",
"view": "nézet",
"noFilters": "nincs konfigurált szűrő"
"noFilters": "nincs konfigurált szűrő",
"countSelected": "{{count}} kiválasztott",
"retry": "újra"
},
"entity": {
"albumArtist_one": "Zenész",
@@ -184,7 +192,9 @@
"trackWithCount_one": "{{count}} sáv",
"trackWithCount_other": "{{count}} sávok",
"radioStation_one": "rádió állomás",
"radioStation_other": "rádió állomások"
"radioStation_other": "rádió állomások",
"radioStationWithCount_one": "{{count}} rádióállomás",
"radioStationWithCount_other": "{{count}} rádióállomások"
},
"error": {
"apiRouteError": "a kérést nem sikerült célba juttatni",
@@ -210,7 +220,12 @@
"serverRequired": "szerver szükséges",
"serverNotSelectedError": "nincs szerver kiválasztva",
"notificationDenied": "Az értesítések engedélyezése megtagadva. Ez a beállítás hatástalan",
"badValue": "érvénytelen opció \"{{value}}\". ez az érték már nem létezik"
"badValue": "érvénytelen opció \"{{value}}\". ez az érték már nem létezik",
"noNetwork": "Szerver nem elérhető",
"noNetworkDescription": "Nem tudok csatlakozni a szerverhez",
"saveQueueFailed": "műsorlista mentése sikertelen",
"settingsSyncError": "Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást",
"multipleServerSaveQueueError": "a műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott"
},
"filter": {
"albumCount": "$t(entity.album_other) darab",
@@ -345,6 +360,16 @@
"input_played": "csak szűrt zenék",
"input_played_optionUnplayed": "Csak a még nem lejátszottak",
"input_played_optionPlayed": "Csak a játszottak számok"
},
"createRadioStation": {
"success": "rádió állomás sikeresen létrehozva",
"title": "rádió állomás létrehozása",
"input_homepageUrl": "oldal url",
"input_name": "név",
"input_streamUrl": "stream url"
},
"saveQueue": {
"success": "mentett lejátszási műsorlista a szerverre"
}
},
"dragDropZone": {
@@ -525,7 +550,8 @@
"settings": "$t(common.setting_other)",
"shared": "megosztott $t(entity.playlist_other)",
"tracks": "$t(entity.track_other)",
"favorites": "$t(entity.favorite_other)"
"favorites": "$t(entity.favorite_other)",
"radio": "$t(entity.radioStation_other)"
},
"trackList": {
"artistTracks": "dalok tőle {{artist}}",
@@ -537,11 +563,14 @@
},
"folderList": {
"title": "$t(entity.folder_other)"
},
"radioList": {
"title": "rádió állomások"
}
},
"player": {
"addLast": "utoljára hozzáadva",
"addNext": "következő hozzáadása",
"addLast": "utolsónak",
"addNext": "következő",
"favorite": "kedvenc",
"mute": "némítás",
"muted": "némítva",
@@ -571,13 +600,15 @@
"pause": "szünet",
"viewQueue": "műsorlista megtekintése",
"shuffle_off": "kevert lejátszás ki",
"addLastShuffled": "Hozzáadás a végére (keverve)",
"addNextShuffled": "Hozzáadás következőnek (keverve)",
"addLastShuffled": "végére (keverve)",
"addNextShuffled": "következő (keverve)",
"queueType": "lekérdezés típus",
"queueType_default": "alapértelmezett",
"queueType_priority": "prioritás",
"holdToShuffle": "tartsd lenyomva a keveréshez",
"lyrics": "dalszöveg"
"lyrics": "dalszöveg",
"saveQueueToServer": "műsorlista mentése a szerverre",
"restoreQueueFromServer": "műsorlista visszaállítása a szerverről"
},
"releaseType": {
"primary": {
@@ -766,7 +797,6 @@
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playButtonBehavior": "lejátszás gomb viselkedése",
"playerAlbumArtResolution_description": "A nagy lejátszó albumborító-előnézetének felbontása. A nagyobb érték élesebb képet ad, de lassíthatja a betöltést. Alapértelmezés: 0, ami az automatikus módot jelenti",
"minimumScrobblePercentage_description": "a szám lejátszásának minimális százaléka, amelynek el kell hangzania, mielőtt Scrobble-nak számít",
"minimumScrobblePercentage": "Minimális Scrobble arány (százalék)",
"minimumScrobbleSeconds": "Minimum Scrobble arány (mp)",
@@ -780,7 +810,6 @@
"notify": "bekapcsolja a dal értesítéseket",
"notify_description": "értesítések megjelenítése az aktuális dal megváltoztatásakor",
"playbackStyle_description": "válaszd ki az lejátszóhoz használni kívánt lejátszási stílust",
"playerAlbumArtResolution": "lejátszó albumborító felbontás",
"playerbarOpenDrawer_description": "lehetővé teszi a lejátszósávra kattintással a teljes képernyős lejátszó megnyitását",
"playerbarOpenDrawer": "lejátszósáv teljes képernyőre váltás",
"preferLocalLyrics_description": "ha elérhető, akkor a távoli dalszövegek helyett a helyi dalszövegeket részesítse előnyben",
@@ -908,7 +937,9 @@
"playerFilters_description": "a következő kritériumok alapján kihagyja a dalokat a műsorlistából",
"playerbarSlider_description": "a hullámforma nem ajánlott lassú vagy korlátozott internetkapcsolat esetén",
"audioFadeOnStatusChange": "audio behúzás állapotváltozáskor",
"audioFadeOnStatusChange_description": "lehetővé teszi a lehúzást és a behúzást, amikor a lejátszás/szünet állapot megváltozik"
"audioFadeOnStatusChange_description": "lehetővé teszi a lehúzást és a behúzást, amikor a lejátszás/szünet állapot megváltozik",
"useThemeAccentColor": "használd a téma kiemelő színét",
"useThemeAccentColor_description": "a kiválasztott témában meghatározott alapszínt használja az egyéni kiemelő szín helyett"
},
"table": {
"config": {
@@ -1038,5 +1069,11 @@
"matchesRegex": "illeszkedik a regexre",
"is": "van",
"isNot": "nincs"
},
"datetime": {
"minuteShort": "perc",
"secondShort": "mp",
"hourShort": "óra",
"dayShort": "nap"
}
}
-2
View File
@@ -579,8 +579,6 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "resolusi sampul album pemutar",
"playerAlbumArtResolution_description": "resolusi untuk pratinjau sampul album pemutar besar. semakin besar akan membuatnya lebih tajam, tetapi dapat memperlambat pemuatan. Nilai default adalah 0, yang berarti otomatis",
"playerbarOpenDrawer": "Buka pemutar ke layar penuh",
"playerbarOpenDrawer_description": "Izinkan mengklik bilah pemutar untuk membuka pemutar di layar penuh",
"remotePassword": "kata sandi kontrol jarak jauh server",
-2
View File
@@ -355,8 +355,6 @@
"passwordStore": "Archivio di password/segreti",
"passwordStore_description": "specifica quale archivio di password e segreti utilizzare. modificalo in caso di problemi nel salvataggio delle credenziali",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "risoluzione della copertina nel lettore",
"playerAlbumArtResolution_description": "la risoluzione dellanteprima della copertina nel lettore in formato grande. valori più alti la rendono più nitida, ma possono rallentare il caricamento. Il valore predefinito è 0, che indica la modalità automatica",
"sidePlayQueueStyle_optionAttached": "fissata",
"sidePlayQueueStyle_optionDetached": "sganciata",
"startMinimized": "avvia minimizzato",
-2
View File
@@ -203,7 +203,6 @@
"volumeWidth_description": "音量スライダーの幅",
"volumeWidth": "音量スライダーの幅",
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。それ以外の場合は無効にしてください",
"playerAlbumArtResolution_description": "大画面プレーヤーのアルバムアートプレビューの解像度。解像度が高いほど鮮明になりますが、読み込みが遅くなる可能性があります。デフォルトは 0 (自動設定) です",
"mpvExtraParameters_help": "1 行に 1 つずつ",
"musicbrainz_description": "MusicBrainz ID が存在するアーティスト/アルバムページに MusicBrainz へのリンクを表示します",
"musicbrainz": "MusicBrainz リンクを表示する",
@@ -211,7 +210,6 @@
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
"passwordStore_description": "使用するパスワード/シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
"passwordStore": "パスワード/シークレットストア",
"playerAlbumArtResolution": "プレーヤーのアルバムアートの解像度",
"playerbarOpenDrawer_description": "プレーヤーバーをクリックすると全画面プレーヤーが開きます",
"preferLocalLyrics_description": "利用可能な場合は、リモート歌詞よりもローカル歌詞を優先します",
"preferLocalLyrics": "ローカル歌詞を優先する",
+32 -3
View File
@@ -21,7 +21,23 @@
},
"viewPlaylists": "$t(entity.playlist_other) 보기",
"setRating": "평점 지정",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) 편집기 펼치기"
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) 편집기 펼치기",
"addOrRemoveFromSelection": "선택항목에서 추가 또는 제거",
"selectRangeOfItems": "항목의 범위 선택",
"createRadioStation": "$t(entity.radioStation_one) 생성",
"deleteRadioStation": "$t(entity.radioStation_one) 삭제",
"selectAll": "전부 선택",
"downloadStarted": "{{count}}개 항목 다운로드 시작했습니다",
"moveUp": "위로 옮기기",
"moveDown": "아래로 옮기기",
"holdToMoveToTop": "맨 위로 옮기기 위해 끌기",
"holdToMoveToBottom": "맨 아래로 옮기기 위해 끌기",
"moveItems": "항목 옮기기",
"shuffle": "섞기",
"shuffleAll": "모두 섞기",
"shuffleSelected": "선택항목 섞기",
"viewMore": "더 보기",
"openApplicationDirectory": "앱 디렉토리 열기"
},
"common": {
"translation": "번역",
@@ -122,7 +138,18 @@
"recordLabel": "레이블",
"releaseType": "발매형태",
"explicit": "성인컨텐츠",
"clean": "클린"
"clean": "클린",
"countSelected": "{{count}}개 선택됨",
"doNotShowAgain": "다시 보지 않기",
"view": "보기",
"externalLinks": "외부 링크",
"faster": "빠르게",
"noFilters": "필터 미설정",
"slower": "천천히",
"sort": "정렬",
"gridRows": "행 그리드",
"tableColumns": "테이블 열",
"itemsMore": "{{count}}개 더"
},
"entity": {
"albumWithCount_other": "{{count}} 앨범",
@@ -142,7 +169,9 @@
"play_other": "{{count}} 재생",
"playlistWithCount_other": "{{count}} 재생목록",
"smartPlaylist": "스마트 $t(entity.playlist_one)",
"track_other": "트랙"
"track_other": "트랙",
"radioStation_other": "라디오 방송국",
"radioStationWithCount_other": "{{count}}개 라디오 방송국"
},
"error": {
"systemFontError": "시스템 폰트를 가져오는데 실패하였습니다",
+20 -3
View File
@@ -27,7 +27,17 @@
"shuffle": "shuffle",
"shuffleAll": "shuffle alles",
"shuffleSelected": "shuffle geselecteerde",
"viewMore": "bekijk meer"
"viewMore": "bekijk meer",
"addOrRemoveFromSelection": "toevoegen of verwijderen van selectie",
"selectRangeOfItems": "selecteer een reeks van nummers",
"createRadioStation": "maak $t(entity.radioStation_one)",
"deleteRadioStation": "verwijder $t(entity.radioStation_one)",
"selectAll": "selecteer alles",
"moveUp": "beweeg naar boven",
"moveDown": "beweeg naar beneden",
"holdToMoveToTop": "ingedrukt houden om naar boven te verplaatsen",
"holdToMoveToBottom": "ingedrukt houden om naar beneden te verplaatsen",
"openApplicationDirectory": "applicatiefolder openen"
},
"common": {
"backward": "achteruit",
@@ -139,7 +149,10 @@
"clean": "schoon",
"gridRows": "rasterrijen",
"tableColumns": "tabelkolommen",
"itemsMore": "{{count}} meer"
"itemsMore": "{{count}} meer",
"countSelected": "{{count}} geselecteerd",
"view": "bekijken",
"noFilters": "geen filters ingesteld"
},
"filter": {
"rating": "rating",
@@ -427,7 +440,11 @@
"song_one": "lied",
"song_other": "liedjes",
"play_one": "{{count}} keer afgespeeld",
"play_other": "{{count}} keren afgespeeld"
"play_other": "{{count}} keren afgespeeld",
"radioStation_one": "radiostation",
"radioStation_other": "radiostations",
"radioStationWithCount_one": "{{count}} radiostation",
"radioStationWithCount_other": "{{count}} radiostations"
},
"table": {
"column": {
+185 -9
View File
@@ -33,7 +33,11 @@
"holdToMoveToTop": "przytrzymaj aby, przesunąć na górę",
"holdToMoveToBottom": "przytrzymaj aby, przesunąć na dół",
"createRadioStation": "utwórz $t(entity.radioStation_one)",
"deleteRadioStation": "usuń $t(entity.radioStation_one)"
"deleteRadioStation": "usuń $t(entity.radioStation_one)",
"addOrRemoveFromSelection": "dodaj lub usuń z wyboru",
"selectRangeOfItems": "wybierz zakres elementów",
"selectAll": "wybierz wszystkie",
"openApplicationDirectory": "otwórz katalog aplikacji"
},
"common": {
"increase": "zwiększ",
@@ -150,7 +154,9 @@
"tableColumns": "tabela kolumn",
"itemsMore": "{{count}} więcej",
"noFilters": "nie skonfigurowano filtrów",
"view": "wyświetl"
"view": "wyświetl",
"countSelected": "wybrano {{count}}",
"retry": "spróbuj ponownie"
},
"entity": {
"genre_one": "gatunek",
@@ -238,7 +244,10 @@
"badValue": "niewłaściwa opcja \"{{value}}\". ta wartość już nie istnieje",
"notificationDenied": "odmówiono uprawnień dla powiadomień. to ustawienie nie będzie miało efektu",
"multipleServerSaveQueueError": "kolejka odtwarzania ma jedną lub więcej piosenek które nie pochodzą z aktualnego serwera. to nie jest wspierane",
"saveQueueFailed": "nie udało się zapisać kolejki"
"saveQueueFailed": "nie udało się zapisać kolejki",
"settingsSyncError": "zostały znalezione różnice pomiędzy ustawieniami w rendererze a głównym procesem. uruchom aplikację ponownie aby, zastosować zmiany",
"noNetwork": "serwer niedostępny",
"noNetworkDescription": "nie udało się połączyć z tym serwerem"
},
"filter": {
"mostPlayed": "najczęściej odtwarzane",
@@ -383,6 +392,11 @@
"input_homepageUrl": "url strony głównej",
"input_name": "nazwa",
"input_streamUrl": "url strumienia"
},
"lyricsExport": {
"export": "eksportuj tekst",
"input_synced": "eksportuj zsynchronizowany tekst",
"input_offset": "$t(setting.lyricOffset)"
}
},
"page": {
@@ -521,7 +535,8 @@
"transcoding": "transkodowanie",
"discord": "discord",
"playerFilters": "filtry odtwarzacza",
"logger": "logger"
"logger": "logger",
"lyricsDisplay": "wyświetlanie tekstu"
},
"trackList": {
"title": "$t(entity.track_other)",
@@ -548,7 +563,9 @@
"viewDiscography": "przeglądaj dyskografię",
"relatedArtists": "powiązane z $t(entity.artist_other)",
"appearsOn": "pojawia się na",
"viewAllTracks": "zobacz wszystko $t(entity.track_other)"
"viewAllTracks": "zobacz wszystko $t(entity.track_other)",
"groupingTypeAll": "wszystkie typy wydań",
"groupingTypePrimary": "główne typy wydań"
},
"itemDetail": {
"copyPath": "kopiuj ścieżkę do schowka",
@@ -616,7 +633,9 @@
"holdToShuffle": "przytrzymaj aby odtwarzać losowo",
"lyrics": "tekst",
"restoreQueueFromServer": "przywróć kolejkę z serwera",
"saveQueueToServer": "zapisz kolejkę na serwerze"
"saveQueueToServer": "zapisz kolejkę na serwerze",
"artistRadio": "radio wykonawcy",
"trackRadio": "radio utworu"
},
"setting": {
"crossfadeStyle_description": "wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
@@ -779,12 +798,10 @@
"clearQueryCache_description": "\"miękkie wyczyszczenie\" feishin. spowoduje to odświeżenie playlist, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane",
"buttonSize_description": "rozmiar przycisków paska odtwarzacza",
"clearCache": "wyczyść pamięć podręczną przeglądarki",
"playerAlbumArtResolution": "rozdzielczość okładki albumu odtwarzacza",
"externalLinks": "pokaż zewnętrzne linki",
"mpvExtraParameters_help": "po jednym na linię",
"passwordStore": "hasła",
"passwordStore_description": "jakie hasło ma być używane. zmień to, jeśli masz problemy z przechowywaniem haseł",
"playerAlbumArtResolution_description": "rozdzielczość podglądu okładki albumu w dużym odtwarzaczu. większa sprawia, że wygląda bardziej wyraziście, ale może spowolnić ładowanie. domyślnie 0, czyli auto",
"startMinimized": "uruchom zminimalizowany",
"startMinimized_description": "uruchom aplikację w zasobniku systemowym",
"clearCacheSuccess": "pamięć podręczna została wyczyszczona pomyślnie",
@@ -926,7 +943,18 @@
"logLevel_optionInfo": "info",
"logLevel_optionWarn": "ostrzeżenia",
"useThemeAccentColor": "używaj koloru akcentu motywu",
"useThemeAccentColor_description": "używaj głównego koloru ustawionego w wybranym motywie zamiast niestandardowego koloru akcentu"
"useThemeAccentColor_description": "używaj głównego koloru ustawionego w wybranym motywie zamiast niestandardowego koloru akcentu",
"artistRadioCount_description": "ustawia liczbę piosenek do załadowania dla radia wykonawcy i radia utworu",
"artistRadioCount": "liczba radio wykonawców/utworów",
"imageResolution": "rozdzielczość obrazu",
"imageResolution_description": "rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną",
"imageResolution_optionTable": "tabela",
"imageResolution_optionItemCard": "karta elementu",
"imageResolution_optionSidebar": "­pasek boczny",
"imageResolution_optionHeader": "nagłówek",
"imageResolution_optionFullScreenPlayer": "odtwarzacz pełnoekranowy",
"combinedLyricsAndVisualizer_description": "połącz tekst i wizualizacje w tym samym panelu",
"combinedLyricsAndVisualizer": "połącz tekst i wizualizacje w pasku bocznym odtwarzacza"
},
"table": {
"config": {
@@ -1084,5 +1112,153 @@
"notInPlaylist": "nie jest w",
"notInTheLast": "nie jest w ostatnim",
"startsWith": "zaczyna się od"
},
"datetime": {
"minuteShort": "min",
"secondShort": "sek",
"hourShort": "godz",
"dayShort": "dzień"
},
"visualizer": {
"visualizerType": "Typ Wizualizacji",
"cycleTime": "Czas cyklu (w sekundach)",
"copyConfiguration": "Kopiuj Konfigurację",
"pasteConfiguration": "Wklej Konfigurację",
"pasteConfigurationPlaceholder": "Wklej konfigurację JSON tutaj...",
"pasteFromClipboard": "Wklej z schowka",
"applyConfiguration": "Zastosuj Konfigurację",
"configCopied": "Konfiguracja skopiowana do schowka",
"configCopyFailed": "Nie udało się skopiować konfiguracji",
"configPasted": "Konfiguracja zastosowana pomyślnie",
"configPasteFailed": "Nie udało się zastosować konfiguracji. Sprawdź jej format.",
"configPasteReadFailed": "Nie udało się odczytać z schowka",
"cyclePresets": "Cykl Ustawień",
"includeAllPresets": "Uwzględnij wszystkie Ustawienia",
"ignoredPresets": "Ignorowane Ustawienia",
"selectedPresets": "Wybrane Ustawienia",
"randomizeNextPreset": "Losuj Następne Ustawienie",
"blendTime": "Czas Mieszania",
"presets": "Ustawienia",
"selectPreset": "Wybierz Ustawienie",
"applyPreset": "Zastosuj Ustawienie",
"saveAsPreset": "Zapisz jako Ustawienie",
"updatePreset": "Uaktualnij Ustawienie",
"presetName": "Nazwa Ustawienia",
"presetNamePlaceholder": "Wpisz nazwę ustawienia",
"general": "Ogólne",
"mode": "Tryb",
"mode1To8": "Tryb 1 - 8",
"mode10": "Tryb 10",
"barSpace": "Odstęp Pasków",
"lineWidth": "Szerokość Linii",
"fillAlpha": "Wypełnij Alpha",
"channelLayout": "Układ Kanałów",
"maxFPS": "Maks FPS",
"opacity": "Nieprzezroczystość",
"customGradients": "­Niestandardowe Gradienty",
"addCustomGradient": "Dodaj Niestandardowy Gradient",
"gradientName": "Nazwa Gradientu",
"gradientNamePlaceholder": "Nazwa Gradientu",
"vertical": "Pionowy",
"horizontal": "Poziomy",
"colorStops": "Kroki Kolorów",
"addColor": "Dodaj Kolor",
"position": "Pozycja",
"level": "Poziom",
"remove": "Usuń",
"custom": "Niestandardowy",
"builtIn": "Wbudowany",
"colors": "Kolory",
"colorMode": "Tryb Koloru",
"gradient": "Gradient",
"gradientLeft": "Lewa Gradientu",
"gradientRight": "Prawa Gradientu",
"fft": "FFT",
"fftSize": "Rozmiar FFT",
"smoothing": "Wygładzanie",
"frequencyRangeAndScaling": "Zakres częstotliwości i skalowanie",
"minimumFrequency": "Minimalna Częstotliwość",
"maximumFrequency": "Maksymalna Częstotliwość",
"frequencyScale": "Skala Częstotliwości",
"sensitivity": "Czułość",
"weightingFilter": "Filtr Wagi",
"minimumDecibels": "Minimum Decybeli",
"maximumDecibels": "Maksimum Decybeli",
"linearAmplitude": "Amplituda Linearna",
"linearBoost": "Podbicie Linearne",
"peakBehavior": "Zachowanie Szczytów",
"showPeaks": "Pokaż Szczyty",
"fadePeaks": "Zanikaj Sczyty",
"peakLine": "Linia Szczytów",
"gravity": "Grawitacja",
"peakFadeTime": "Czas Zanikania Szczytów (ms)",
"peakHoldTime": "Czas Utrzymywania Szczytu (ms)",
"radialSpectrum": "Spektrum Promieniowe",
"radial": "Promieniowe",
"radialInvert": "Odwrócenie Promieniowe",
"spinSpeed": "Prędkość Obrotu",
"radius": "Promień",
"reflexMirror": "Lustro refleksyjne",
"reflexFit": "Dopasowanie Odbić",
"reflexRatio": "Współczynnik Odbić",
"reflexAlpha": "Alpha Odbić",
"reflexBrightness": "Jasność Odbić",
"mirror": "Odbij lustrzanie",
"miscellaneousSettings": "Różne Ustawienia",
"alphaBars": "Alpha Pasków",
"ledBars": "Paski LED",
"trueLeds": "Prawdziwe LEDy",
"lumiBars": "Paski Lumi",
"outlineBars": "Obwódki Pasków",
"roundBars": "Zaokrąglone Paski",
"lowResolution": "­Niska Rozdzielczość",
"splitGradient": "Rozdziel Gradient",
"showFPS": "Pokaż FPS",
"showScaleX": "Pokaż Skalę X",
"noteLabels": "Etykiety Nut",
"showScaleY": "Pokaż Skalę Y",
"options": {
"mode": {
"bars": "[0] Pasków",
"circle": "[1] Kółko",
"wave": "[2] Fala",
"rainbow": "[3] Tęcza",
"rings": "[4] Pierścienie",
"mirror": "[5] Lustro",
"line": "[6] Linia",
"particles": "[7] Cząsteczki",
"fullOctave": "[8] Pełna oktawa / 10 pasm",
"outlineBars": "[10] Paski z obwódką"
},
"colorMode": {
"gradient": "Gradient",
"barIndex": "Indeks-Paska",
"barLevel": "Poziom-Paska"
},
"gradient": {
"classic": "Klasyczny",
"prism": "Pryzmat",
"rainbow": "Tęcza",
"steelblue": "Stalowoniebieski",
"orangered": "Pomarańczowo-czerwony"
},
"channelLayout": {
"single": "Pojedynczy",
"dualCombined": "Podwójne-Połączone",
"dualHorizontal": "Podwójne-Poziome",
"dualVertical": "Podwójne-Pionowe"
},
"frequencyScale": {
"linear": "Linearne"
},
"weightingFilter": {
"none": "Żadne",
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z"
}
}
}
}
+2 -2
View File
@@ -356,8 +356,6 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "resolução da capa do álbum no reprodutor",
"playerAlbumArtResolution_description": "a resolução da pré-visualização da capa do álbum no reprodutor grande. Resoluções maiores deixam a imagem mais nítida, mas podem diminuir a velocidade de carregamento. O padrão é 0, ou seja, automático",
"playerbarOpenDrawer": "alternar tela cheia na barra do reprodutor",
"playerbarOpenDrawer_description": "permite clicar na barra do reprodutor para abrir o reprodutor em tela cheia",
"remotePassword": "Senha do servidor de controle remoto",
@@ -383,6 +381,8 @@
"savePlayQueue_description": "Salvar a fila de reprodução ao fechar a aplicação e restaurá-la ao abrir a aplicação",
"scrobble": "Scrobblar",
"scrobble_description": "Scrobblar reproduções para o seu servidor de mídia",
"showRatings": "exibir avaliações por estrelas",
"showRatings_description": "exibir ou ocultar as avaliações por estrelas",
"showSkipButton": "Exibir botões de pular",
"showSkipButton_description": "Exibir ou ocultar os botões de pular na barra do reprodutor",
"showSkipButtons": "Exibir botões de pular",
-2
View File
@@ -670,7 +670,6 @@
"playButtonBehavior": "поведение кнопки воспроизведения",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playerAlbumArtResolution_description": "разрешение большой версии обложки альбома в проигрывателе. при большем разрешении она выглядит более четкой, но может замедлить загрузку. по умолчанию равно 0 - устанавливает разрешение автоматически",
"playerbarOpenDrawer": "полноэкранный переключатель по панели проигрывателя",
"playerbarOpenDrawer_description": "позволяет перейти в полноэкранный режим воспроизведения нажатием на панель проигрывателя",
"remotePort": "порт сервера удалённого управления",
@@ -711,7 +710,6 @@
"imageAspectRatio_description": "если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым",
"minimumScrobblePercentage": "минимальное время для скробблинга (в процентах)",
"playbackStyle": "стиль воспроизведения",
"playerAlbumArtResolution": "разрешение обложки альбома",
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам неважен",
"replayGainClipping_description": "Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления",
"replayGainFallback_description": "усиление в db для применения, если у файла нет тегов {{ReplayGain}}",
-2
View File
@@ -654,8 +654,6 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "rozlíšenie obrázka albumu",
"playerAlbumArtResolution_description": "rozlíšenie zobrazenia náhľadu veľkých obrázkov albumov. pri väčšom rozlíšení budú krajšie, ale môže sa spomaliť ich načítavanie. predvolené je 0, čo znamená automatické",
"playerbarOpenDrawer": "zobrazenie na celú obrazovku panelom prehrávača",
"playerbarOpenDrawer_description": "umožní kliknutím na panel prehrávača prepnúť zobrazenie prehrávača na celú obrazovku",
"remotePassword": "heslo servera vzdialeného ovládania",
+80 -9
View File
@@ -33,7 +33,11 @@
"musicbrainz": "Öppna i MusicBrainz"
},
"createRadioStation": "skapa $t(entity.radioStation_one)",
"deleteRadioStation": "ta bort $t(entity.radioStation_one)"
"deleteRadioStation": "ta bort $t(entity.radioStation_one)",
"addOrRemoveFromSelection": "lägg till eller ta bort från markerade",
"selectRangeOfItems": "välj en mängd objekt",
"selectAll": "markera alla",
"openApplicationDirectory": "öppna applikationskatalog"
},
"common": {
"backward": "bakåt",
@@ -143,7 +147,8 @@
"clean": "städad",
"gridRows": "rutnätsrader",
"tableColumns": "tabellkolumner",
"itemsMore": "{{count}} fler"
"itemsMore": "{{count}} fler",
"countSelected": "{{count}} markerade"
},
"error": {
"remotePortWarning": "starta om servern för att tillämpa den nya porten",
@@ -166,7 +171,12 @@
"invalidServer": "ogiltig server",
"loginRateError": "för många inloggningsförsök, försök igen om några sekunder",
"badAlbum": "du ser denna sidan eftersom denna låten inte är en del av ett album. du ser troligtvis detta problemet för att du har en låt på toppnivån i din musikmapp. Jellyfin grupperar bara låtar om de finns i en mapp",
"badValue": "felaktigt alternativ \"{{value}}\". detta värde existerar inte längre"
"badValue": "felaktigt alternativ \"{{value}}\". detta värde existerar inte längre",
"multipleServerSaveQueueError": "spelningskön har en eller flera låtar som inte är från den nuvarande valda servern. detta är inte stöttat",
"networkError": "en nätverksfel uppstod",
"notificationDenied": "åtkomst till notifieringarna var nekad. inställningen har ingen verkan",
"openError": "kunde inte öppna filen",
"settingsSyncError": "diskrepans hittades mellan inställningarna för renderingsprocessen och huvudprocessen. starta om applikationen för att ändringarna ska tillämpas"
},
"filter": {
"mostPlayed": "mest spelade",
@@ -209,7 +219,9 @@
"album": "$t(entity.album_one)",
"trackNumber": "spår",
"songCount": "sångräkning",
"criticRating": "kritikerbetyg"
"criticRating": "kritikerbetyg",
"albumCount": "$t(entity.album_other) antal",
"explicitStatus": "$t(common.explicitStatus)"
},
"form": {
"deletePlaylist": {
@@ -236,13 +248,17 @@
"input_savePassword": "spara lösenord",
"ignoreSsl": "ignorera ssl ($t(common.restartRequired))",
"ignoreCors": "ignorera cors ($t(common.restartRequired))",
"error_savePassword": "ett fel uppstod när lösenordet skulle sparas"
"error_savePassword": "ett fel uppstod när lösenordet skulle sparas",
"input_preferInstantMix": "föredra instant mixning",
"input_preferInstantMixDescription": "använd bara instant mixning för att få liknande låtar. användbar om du har plugin för att förändra detta beteendet"
},
"addToPlaylist": {
"success": "tillade {{message}} $t(entity.track_other) til {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "lade till $t(entity.trackWithCount, {\"count\": {{message}} }) till $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "lägg till i $t(entity.playlist_one)",
"input_skipDuplicates": "hoppa över dubbletter",
"input_playlists": "$t(entity.playlist_other)"
"input_playlists": "$t(entity.playlist_other)",
"create": "skapa $t(entity.playlist_one) {{playlist}}",
"searchOrCreate": "sök $t(entity.playlist_other) eller skriv för att skapa en ny"
},
"updateServer": {
"title": "uppdatera server",
@@ -258,7 +274,19 @@
"title": "sångtext sök"
},
"editPlaylist": {
"title": "redigera $t(entity.playlist_one)"
"title": "redigera $t(entity.playlist_one)",
"publicJellyfinNote": "Jellyfin visar av någon anledning inte om en spellista är publik eller inte. Om du önskar att denna ska förbli publik, så får du ha följande indata markerade"
},
"largeFetchConfirmation": {
"title": "lägg till objekt till kön",
"description": "Åtgärden kommer att lägga till alla objekt till den nuvarande filtrerade vyn"
},
"createRadioStation": {
"success": "radiostation skapades",
"title": "skapa radiostation",
"input_homepageUrl": "hemside-URL",
"input_name": "namn",
"input_streamUrl": "stream url"
}
},
"page": {
@@ -306,7 +334,17 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} vald",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"download": "ladda ner",
"moveItems": "$t(action.moveItems)",
"moveToNext": "$t(action.moveToNext)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "dela objekt",
"goTo": "gå till",
"goToAlbum": "gå till $t(entity.album_one)",
"goToAlbumArtist": "gå till $t(entity.albumArtist_one)",
"showDetails": "hämta information"
},
"albumDetail": {
"moreFromArtist": "mer från $t(entity.artist_one)",
@@ -340,6 +378,12 @@
"searchFor": "sök efter {{query}}"
},
"title": "kommandon"
},
"manageServers": {
"url": "URL",
"username": "användarnamn",
"editServerDetailsTooltip": "redigera serverinställningar",
"removeServer": "ta bort server"
}
},
"entity": {
@@ -405,5 +449,32 @@
"queue_moveToBottom": "flytta markerad till toppen",
"addLast": "lägg till sist",
"mute": "muta"
},
"datetime": {
"minuteShort": "min",
"secondShort": "sek",
"hourShort": "h",
"dayShort": "dag"
},
"filterOperator": {
"after": "är efter",
"afterDate": "är efter (datum)",
"before": "är före",
"beforeDate": "är före (datum)",
"contains": "innehåller",
"endsWith": "slutar med",
"inPlaylist": "är inom",
"inTheLast": "är i den sista",
"inTheRange": "är i spannet",
"inTheRangeDate": "är i spannet (datum)",
"is": "är",
"isNot": "är inte",
"isGreaterThan": "är större än",
"isLessThan": "är mindre än",
"matchesRegex": "matchar regex",
"notContains": "innehåller inte",
"notInPlaylist": "är inte inom",
"notInTheLast": "är inte inom den sista",
"startsWith": "startar med"
}
}
-2
View File
@@ -550,8 +550,6 @@
"playButtonBehavior_description": "வரிசையில் பாடல்களைச் சேர்க்கும்போது ப்ளே பொத்தானின் இயல்புநிலை நடத்தை அமைக்கிறது",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playerAlbumArtResolution": "பிளேயர் ஆல்பம் கலைத் தீர்மானம்",
"playerAlbumArtResolution_description": "பெரிய வீரரின் ஆல்பம் கலை முன்னோட்டத்திற்கான தீர்மானம். பெரியது இது மிகவும் மிருதுவானதாக தோற்றமளிக்கிறது, ஆனால் மெதுவாக ஏற்றுவதை மெதுவாகக் கொண்டிருக்கலாம். இயல்புநிலை 0 க்கு, அதாவது ஆட்டோ",
"playerbarOpenDrawer": "பிளேயர்பார் முழுத்திரை மாற்று",
"playerbarOpenDrawer_description": "முழு திரை பிளேயரைத் திறக்க பிளேயர்பாரைக் சொடுக்கு செய்ய அனுமதிக்கிறது",
"remotePassword": "ரிமோட் கண்ட்ரோல் சர்வர் கடவுச்சொல்",
+11 -4
View File
@@ -21,7 +21,15 @@
"goToPage": "sayfaya git",
"moveToNext": "sonrakine geç",
"refresh": "$t(common.refresh)",
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) düzenleyiciye geç"
"toggleSmartPlaylistEditor": "$t(entity.smartPlaylist) düzenleyiciye geç",
"addOrRemoveFromSelection": "seçime ekle veya seçimi kaldır",
"selectRangeOfItems": "bir dizi öğe seçin",
"createRadioStation": "$t(entity.radioStation_one) oluştur",
"deleteRadioStation": "$t(entity.radioStation_one) istasyonunu sil",
"selectAll": "tümünü seç",
"downloadStarted": "{{count}} öğenin indirilmesine başlandı",
"moveUp": "yukarı kaydır",
"moveDown": "aşağı kaydır"
},
"common": {
"action_one": "eylem",
@@ -120,7 +128,8 @@
"trackGain": "parça kazancı",
"trackPeak": "parça zirvesi",
"private": "gizli",
"clean": "temiz"
"clean": "temiz",
"countSelected": "{{count}} adet seçildi"
},
"entity": {
"album_one": "albüm",
@@ -604,8 +613,6 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "oynatıcı albüm resmi çözünürlüğü",
"playerAlbumArtResolution_description": "büyük oynatıcının albüm resmi önizlemesi için çözünürlük. daha büyük değerler daha net görünmesini sağlar, ancak yüklemeyi yavaşlatabilir. varsayılan değer 0, otomatik olarak çalışır",
"playerbarOpenDrawer": "oynatma çubuğu tam ekran geçişi",
"playerbarOpenDrawer_description": "tam ekran oynatıcıyı açmak için oynatma çubuğuna tıklamaya izin verir",
"remotePassword": "uzaktan kontrol sunucusu şifresi",
+7 -4
View File
@@ -31,7 +31,12 @@
"shuffle": "随机播放",
"shuffleAll": "随机播放全部",
"shuffleSelected": "随机播放选定的内容",
"viewMore": "查看更多"
"viewMore": "查看更多",
"addOrRemoveFromSelection": "在所选内容中添加或移除",
"selectRangeOfItems": "批量选择",
"selectAll": "全选",
"createRadioStation": "创建$t(entity.radioStation_one)",
"deleteRadioStation": "删除$t(entity.radioStation_one)"
},
"common": {
"increase": "增高",
@@ -189,7 +194,7 @@
"queue_moveToTop": "将所选项移至底部",
"queue_moveToBottom": "将所选项移至顶部",
"shuffle_off": "禁用随机播放",
"addLast": "添加至播放列表末尾",
"addLast": "上一曲",
"mute": "静音",
"skip_forward": "向前跳过",
"playbackSpeed": "播放速度",
@@ -367,8 +372,6 @@
"startMinimized_description": "在系统托盘中启动应用程序",
"passwordStore_description": "使用什么密码/密钥存储。如果您在存储密码时遇到问题,请更改此设置",
"clearCacheSuccess": "缓存清除成功",
"playerAlbumArtResolution": "播放器专辑封面分辨率",
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
"homeConfiguration": "主页配置",
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
"passwordStore": "密码/密钥存储",
+44 -11
View File
@@ -106,7 +106,9 @@
"explicitStatus": "Explicit狀態",
"explicit": "Explicit",
"gridRows": "網格行",
"noFilters": "未設定任何過濾器"
"noFilters": "未設定任何過濾器",
"countSelected": "{{count}}個已選取",
"retry": "重試"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -134,7 +136,10 @@
"notificationDenied": "通知權限被拒絕。此設定無效",
"openError": "無法開啟檔案",
"multipleServerSaveQueueError": "播放佇列中包含不是來自目前伺服器的歌曲,此操作不受支援",
"saveQueueFailed": "儲存播放佇列失敗"
"saveQueueFailed": "儲存播放佇列失敗",
"settingsSyncError": "偵測到渲染器與主程序之間的設定不一致,請重新啟動應用程式以套用變更",
"noNetwork": "伺服器無法連線",
"noNetworkDescription": "無法連接到此伺服器"
},
"page": {
"contextMenu": {
@@ -248,7 +253,8 @@
"discord": "Discord",
"queryBuilder": "查詢建構器",
"playerFilters": "播放過濾器",
"logger": "日誌記錄器"
"logger": "日誌記錄器",
"lyricsDisplay": "歌詞顯示"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -367,7 +373,9 @@
"holdToShuffle": "按住以隨機",
"lyrics": "歌詞",
"restoreQueueFromServer": "從伺服器還原播放佇列",
"saveQueueToServer": "將播放佇列儲存至伺服器"
"saveQueueToServer": "將播放佇列儲存至伺服器",
"artistRadio": "藝人電台",
"trackRadio": "曲目電台"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -403,7 +411,7 @@
"discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)",
"enableRemote": "啟用遠端控制伺服器",
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
"exitToTray": "退出時最小化到系統匣",
"exitToTray": "關閉時到將視窗最小化",
"followLyric": "跟隨目前歌詞",
"font_description": "設定應用程式使用的字體",
"fontType": "字體類型",
@@ -448,7 +456,7 @@
"lyricOffset": "歌詞偏移(毫秒)",
"lyricOffset_description": "將歌詞偏移指定的毫秒數",
"lyricFetchProvider_description": "選擇歌詞來源。 來源順序即為搜尋的順序",
"minimizeToTray": "最小化到匣",
"minimizeToTray": "最小化到系統匣",
"minimizeToTray_description": "將應用程式最小化到系統匣",
"minimumScrobbleSeconds": "最小紀錄時間(秒)",
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(scrobble)所需的最小播放時間",
@@ -572,11 +580,9 @@
"passwordStore": "密碼/secret儲存",
"passwordStore_description": "使用什麼密碼/secret儲存。如果您在儲存密碼時遇到問題,請變更此項目",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "播放器專輯封面解析度",
"playerAlbumArtResolution_description": "大型播放器專輯封面預覽的解析度。較大的解析度使其看起來更清晰,但可能會減慢載入速度。預設為 0,表示自動",
"playerbarOpenDrawer": "播放器列全螢幕切換",
"playerbarOpenDrawer_description": "允許點擊播放器列以開啟全螢幕播放器",
"startMinimized": "最小化啟動",
"startMinimized": "啟動時最小化",
"startMinimized_description": "在系統匣中啟動應用程式",
"transcode_description": "啟用轉碼到不同格式",
"transcodeBitrate": "要轉碼的比特率",
@@ -677,7 +683,17 @@
"logLevel_optionInfo": "Info",
"logLevel_optionWarn": "Warn",
"useThemeAccentColor": "使用主題強調色",
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色"
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色",
"artistRadioCount_description": "設定為藝人電台與曲目電台擷取的歌曲數量",
"imageResolution": "圖片解析度",
"imageResolution_description": "應用程式中所使用圖片的解析度。設定為 0 時,將使用圖片的原始解析度",
"imageResolution_optionTable": "表格",
"imageResolution_optionItemCard": "項目卡片",
"imageResolution_optionSidebar": "側邊欄",
"imageResolution_optionHeader": "頁首",
"imageResolution_optionFullScreenPlayer": "全螢幕播放器",
"combinedLyricsAndVisualizer_description": "將歌詞與視覺化效果整合至同一個面板",
"combinedLyricsAndVisualizer": "在播放器側邊欄整合歌詞與視覺化效果"
},
"table": {
"config": {
@@ -817,7 +833,10 @@
"holdToMoveToTop": "按住以移動至頂部",
"holdToMoveToBottom": "按住以移動至底部",
"createRadioStation": "創建 $t(entity.radioStation_one)",
"deleteRadioStation": "刪除 $t(entity.radioStation_one)"
"deleteRadioStation": "刪除 $t(entity.radioStation_one)",
"openApplicationDirectory": "開啟應用程式目錄",
"addOrRemoveFromSelection": "新增或移除選取項目",
"selectAll": "全選"
},
"entity": {
"album_other": "專輯",
@@ -984,6 +1003,11 @@
},
"saveQueue": {
"success": "已將播放佇列儲存至伺服器"
},
"lyricsExport": {
"export": "匯出歌詞",
"input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)"
}
},
"releaseType": {
@@ -1035,5 +1059,14 @@
"notContains": "不包含",
"notInPlaylist": "不在…之中",
"startsWith": "以…開頭"
},
"datetime": {
"minuteShort": "分",
"secondShort": "秒",
"hourShort": "小時",
"dayShort": "天"
},
"visualizer": {
"visualizerType": "視覺化效果類型"
}
}
+3
View File
@@ -657,6 +657,9 @@ if (mprisPlayer) {
}
currentState.volume = volume;
broadcast({ data: volume, event: 'volume' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
});
}
+33 -1
View File
@@ -3,14 +3,46 @@ import type { TitleTheme } from '/@/shared/types/types';
import { dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import Store from 'electron-store';
export const store = new Store({
const getFrame = () => {
const isWindows = process.platform === 'win32';
const isMacOS = process.platform === 'darwin';
if (isWindows) {
return 'windows';
}
if (isMacOS) {
return 'macOS';
}
return 'linux';
};
export const store = new Store<any>({
beforeEachMigration: (_store, context) => {
console.log(`settings migrate from ${context.fromVersion}${context.toVersion}`);
},
defaults: {
disable_auto_updates: false,
enableNeteaseTranslation: false,
global_media_hotkeys: true,
mediaSession: false,
playbackType: 'web',
should_prompt_accessibility: true,
shown_accessibility_warning: false,
window_enable_tray: true,
window_exit_to_tray: false,
window_minimize_to_tray: false,
window_start_minimized: false,
window_window_bar_style: getFrame(),
},
migrations: {
'>=0.21.2': (store) => {
store.set('window_bar_style', 'linux');
},
'>=1.0.0': (store) => {
store.clear();
},
},
});
+42 -42
View File
@@ -141,48 +141,48 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle;
});
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
try {
if (!song?.id) {
mprisPlayer.metadata = {};
return;
ipcMain.on(
'update-song',
(_event, song: QueueSong | undefined, imageUrl: null | string | undefined) => {
try {
if (!song?.id) {
mprisPlayer.metadata = {};
return;
}
mprisPlayer.metadata = {
'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length
? song.albumArtists.map((artist) => artist.name)
: null,
'xesam:artist': song.artists?.length
? song.artists.map((artist) => artist.name)
: null,
'xesam:audioBpm': song.bpm,
// Comment is a `list of strings` type
'xesam:comment': song.comment ? [song.comment] : null,
'xesam:contentCreated': song.releaseDate,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length
? song.genres.map((genre: any) => genre.name)
: null,
'xesam:lastUsed': song.lastPlayedAt,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
// User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
'xesam:userRating': song.userRating ? song.userRating / 5 : null,
};
} catch (err) {
console.error(err);
}
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length
? song.albumArtists.map((artist) => artist.name)
: null,
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
'xesam:audioBpm': song.bpm,
// Comment is a `list of strings` type
'xesam:comment': song.comment ? [song.comment] : null,
'xesam:contentCreated': song.releaseDate,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:lastUsed': song.lastPlayedAt,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
// User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
'xesam:userRating': song.userRating ? song.userRating / 5 : null,
};
} catch (err) {
console.error(err);
}
});
},
);
export { mprisPlayer };
+10 -1
View File
@@ -30,6 +30,7 @@ import MenuBuilder from './menu';
import {
autoUpdaterLogInterface,
createLog,
disableAutoUpdates,
hotkeyToElectronAccelerator,
isLinux,
isMacOS,
@@ -456,7 +457,7 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
if (store.get('disable_auto_updates') !== true) {
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
@@ -702,3 +703,11 @@ if (!ipcMain.eventNames().includes('open-item')) {
});
});
}
// Register 'open-application-directory' handler globally, ensuring it is only registered once
if (!ipcMain.eventNames().includes('open-application-directory')) {
ipcMain.handle('open-application-directory', async () => {
const userDataPath = app.getPath('userData');
shell.openPath(userDataPath);
});
}
+4
View File
@@ -18,6 +18,10 @@ if (process.env.NODE_ENV === 'development') {
};
}
export const disableAutoUpdates = () => {
return process.env['DISABLE_AUTO_UPDATES'];
};
export const isMacOS = () => {
return process.platform === 'darwin';
};
+7 -2
View File
@@ -27,8 +27,8 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (song: QueueSong | undefined) => {
ipcRenderer.send('update-song', song);
const updateSong = (song: QueueSong | undefined, imageUrl?: null | string) => {
ipcRenderer.send('update-song', song, imageUrl);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
@@ -51,11 +51,16 @@ const requestToggleShuffle = (
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
};
export const mpris = {
requestPosition,
requestSeek,
requestToggleRepeat,
requestToggleShuffle,
requestVolume,
updatePosition,
updateRepeat,
updateSeek,
+7 -1
View File
@@ -1,11 +1,15 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { isLinux, isMacOS, isWindows } from '../main/utils';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/utils';
const openItem = async (path: string) => {
return ipcRenderer.invoke('open-item', path);
};
const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', cb);
};
@@ -36,12 +40,14 @@ const download = (url: string) => {
};
export const utils = {
disableAutoUpdates,
download,
isLinux,
isMacOS,
isWindows,
logger,
mainMessageListener,
openApplicationDirectory,
openItem,
playerErrorListener,
};
+38 -2
View File
@@ -320,6 +320,20 @@ export const controller: GeneralController = {
query: mergeMusicFolderId(args.query, server),
});
},
getArtistRadio(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getArtistRadio`,
);
}
return apiController(
'getArtistRadio',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getDownloadUrl(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -370,6 +384,20 @@ export const controller: GeneralController = {
query: mergeMusicFolderId(args.query, server),
});
},
getImageUrl(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
return null;
}
return (
apiController(
'getImageUrl',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }) || null
);
},
getInternetRadioStations(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -493,7 +521,11 @@ export const controller: GeneralController = {
return apiController(
'getRandomSongList',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
)?.({
...args,
apiClientProps: { ...args.apiClientProps, server },
query: mergeMusicFolderId(args.query, server),
});
},
getRoles(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -535,7 +567,11 @@ export const controller: GeneralController = {
return apiController(
'getSimilarSongs',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
)?.({
...args,
apiClientProps: { ...args.apiClientProps, server },
query: mergeMusicFolderId(args.query, server),
});
},
getSongDetail(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -360,7 +360,7 @@ export const JellyfinController: InternalControllerEndpoint = {
},
query: {
...artistQuery,
Fields: 'People, Tags',
Fields: 'People, Tags, Studios',
GenreIds: query.genreIds ? query.genreIds.join(',') : undefined,
IncludeItemTypes: 'MusicAlbum',
IsFavorite: query.favorite,
@@ -426,10 +426,31 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
// For Jellyfin, use instant mix for artist radio
const res = await jfApiClient(apiClientProps).getInstantMix({
params: {
itemId: query.artistId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server));
},
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;
},
getFolder: async ({ apiClientProps, query }) => {
const userId = apiClientProps.server?.userId;
@@ -670,6 +691,22 @@ export const JellyfinController: InternalControllerEndpoint = {
totalRecordCount: res.body?.TotalRecordCount || 0,
};
},
getImageUrl: ({ apiClientProps: { server }, query }) => {
const { id, size } = query;
const imageSize = size;
if (!server?.url) {
return null;
}
// For Jellyfin, we construct the URL pattern
// The server will return a 404 or placeholder if no image exists
const baseUrl = `${server.url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`;
// For songs, we might want to fall back to album art, but we don't have albumId here
// The caller can handle this if needed
return baseUrl;
},
getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
@@ -886,7 +923,12 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get server info');
}
const features = getFeatures(VERSION_INFO, res.body.Version);
const defaultFeatures = {};
const features = {
...defaultFeatures,
...getFeatures(VERSION_INFO, res.body.Version),
};
return {
features,
@@ -1077,9 +1119,7 @@ export const JellyfinController: InternalControllerEndpoint = {
}
return {
items: items.map((item) =>
jfNormalize.song(item, apiClientProps.server, query.imageSize),
),
items: items.map((item) => jfNormalize.song(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount,
};
@@ -1093,7 +1133,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const { bitrate, format, id, transcode } = query;
const deviceId = '';
let url = `${server?.url}/Items/${id}/Download?api_key=${server?.credential}&playSessionId=${deviceId}`;
let url = `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;
if (transcode) {
// Some format appears to be required. Fall back to trusty MP3 if not specified
@@ -322,13 +322,17 @@ export const NavidromeController: InternalControllerEndpoint = {
? query.genreIds
: query.genreIds?.[0];
const artistIds = hasFeature(apiClientProps.server, ServerFeature.BFR)
? query.artistIds
: query.artistIds?.[0];
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
artist_id: artistIds,
compilation: query.compilation,
genre_id: genres,
has_rating: query.hasRating,
@@ -401,6 +405,32 @@ export const NavidromeController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
// Use getSimilarSongs2 API for artist radio
const res = await ssApiClient({
...apiClientProps,
silent: true,
}).getSimilarSongs2({
query: {
count: query.count,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
if (!res.body.similarSongs2?.song) {
return [];
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
);
},
getDownloadUrl: SubsonicController.getDownloadUrl,
getFolder: SubsonicController.getFolder,
getGenreList: async (args) => {
@@ -461,6 +491,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations,
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
@@ -664,9 +695,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return {
items: res.body.data.map((song) =>
ndNormalize.song(song, apiClientProps.server, query.imageSize),
),
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
+5
View File
@@ -4,6 +4,7 @@ import type {
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
ArtistRadioQuery,
FolderQuery,
GenreListQuery,
LyricSearchQuery,
@@ -340,6 +341,10 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const,
},
songs: {
artistRadio: (serverId: string, query?: ArtistRadioQuery) => {
if (query) return [serverId, 'songs', 'artistRadio', query] as const;
return [serverId, 'songs', 'artistRadio'] as const;
},
count: (serverId: string, query?: SongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
@@ -201,6 +201,14 @@ export const contract = c.router({
200: ssType._response.similarSongs,
},
},
getSimilarSongs2: {
method: 'GET',
path: 'getSimilarSongs2',
query: ssType._parameters.similarSongs2,
responses: {
200: ssType._response.similarSongs2,
},
},
getSong: {
method: 'GET',
path: 'getSong.view',
@@ -155,7 +155,10 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await ssApiClient(apiClientProps).createFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
artistId:
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
? query.id
: undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
@@ -205,7 +208,10 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await ssApiClient(apiClientProps).removeFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
artistId:
query.type === LibraryItem.ALBUM_ARTIST || query.type === LibraryItem.ARTIST
? query.id
: undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
@@ -273,11 +279,11 @@ export const SubsonicController: InternalControllerEndpoint = {
}
return {
...ssNormalize.albumArtist(artist, apiClientProps.server, 300),
...ssNormalize.albumArtist(artist, apiClientProps.server),
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
similarArtists:
artistInfo?.similarArtist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
ssNormalize.albumArtist(artist, apiClientProps.server),
) || null,
};
},
@@ -297,7 +303,7 @@ export const SubsonicController: InternalControllerEndpoint = {
const artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
let results = artists.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
ssNormalize.albumArtist(artist, apiClientProps.server),
);
if (query.searchTerm) {
@@ -348,6 +354,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: query.startIndex,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
@@ -482,7 +489,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return {
items:
res.body.albumList2.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server, 300),
ssNormalize.album(album, apiClientProps.server),
) || [],
startIndex: query.startIndex,
totalRecordCount: null,
@@ -503,6 +510,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: startIndex,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: 0,
songOffset: 0,
@@ -652,7 +660,7 @@ export const SubsonicController: InternalControllerEndpoint = {
}
let results = artists.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
ssNormalize.albumArtist(artist, apiClientProps.server),
);
if (query.searchTerm) {
@@ -676,6 +684,28 @@ export const SubsonicController: InternalControllerEndpoint = {
...args,
query: { ...args.query, startIndex: 0 },
}).then((res) => res!.totalRecordCount!),
getArtistRadio: async (args) => {
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getSimilarSongs2({
query: {
count: query.count,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist radio songs');
}
if (!res.body.similarSongs2?.song) {
return [];
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
);
},
getDownloadUrl: (args) => {
const { apiClientProps, query } = args;
@@ -821,6 +851,28 @@ export const SubsonicController: InternalControllerEndpoint = {
startIndex: query.startIndex,
});
},
getImageUrl: ({ apiClientProps: { server }, query }) => {
const { id, size } = query;
const imageSize = size;
if (!server?.url || !server?.credential) {
return null;
}
// Check for default placeholder image ID
if (id.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${server.url}/rest/getCoverArt.view` +
`?id=${id}` +
`&${server.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
(imageSize ? `&size=${imageSize}` : '')
);
},
getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
@@ -852,6 +904,7 @@ export const SubsonicController: InternalControllerEndpoint = {
totalRecordCount: res.body.musicFolders.musicFolder.length,
};
},
getPlaylistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -867,7 +920,6 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
},
getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
@@ -1145,6 +1197,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
@@ -1289,6 +1342,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: query.limit,
songOffset: query.startIndex,
@@ -1329,6 +1383,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
@@ -1432,6 +1487,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: 1,
songOffset: sectionIndex,
@@ -1460,6 +1516,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.searchTerm || '',
songCount: MAX_SUBSONIC_ITEMS,
songOffset: startIndex,
@@ -1729,6 +1786,7 @@ export const SubsonicController: InternalControllerEndpoint = {
albumOffset: query.albumStartIndex,
artistCount: query.albumArtistLimit,
artistOffset: query.albumArtistStartIndex,
musicFolderId: getLibraryId(query.musicFolderId),
query: query.query,
songCount: query.songLimit,
songOffset: query.songStartIndex,
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import styles from './drag-preview.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { Icon } from '/@/shared/components/icon/icon';
import { LibraryItem } from '/@/shared/types/domain-types';
import { DragData } from '/@/shared/types/drag-and-drop';
@@ -23,22 +24,19 @@ const getItemName = (item: unknown): string => {
return 'Item';
};
const getItemImage = (item: unknown): null | string => {
if (item && typeof item === 'object') {
if ('imageUrl' in item && typeof item.imageUrl === 'string') {
return item.imageUrl;
}
}
return null;
};
export const DragPreview = memo(({ data }: DragPreviewProps) => {
const items = data.item || [];
const { t } = useTranslation();
const itemCount = items.length;
const firstItem = items[0];
const itemName = firstItem ? getItemName(firstItem) : 'Item';
const itemImage = firstItem ? getItemImage(firstItem) : null;
const itemImage = useItemImageUrl({
id: (firstItem as { id: string })?.id,
itemType: data.itemType || LibraryItem.SONG,
type: 'table',
});
const isMultiple = itemCount > 1;
return (
@@ -177,6 +177,7 @@
}
.artist {
width: 100%;
color: white;
text-shadow: 0 0 8px rgb(0 0 0 / 50%);
}
@@ -1,11 +1,12 @@
import type { MouseEvent } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './feature-carousel.module.css';
import { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -15,7 +16,6 @@ import { useCurrentServer } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem } from '/@/shared/types/domain-types';
@@ -78,9 +78,15 @@ interface CarouselItemProps {
}
const CarouselItem = ({ album }: CarouselItemProps) => {
const imageUrl = useItemImageUrl({
id: album.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const { background: backgroundColor } = useFastAverageColor({
algorithm: 'dominant',
src: album.imageUrl || null,
src: imageUrl || null,
srcLoaded: true,
});
@@ -110,10 +116,12 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
</div>
<div className={styles.imageSection}>
<Image
<ItemImage
className={styles.albumImage}
containerClassName={styles.albumImageContainer}
src={album.imageUrl || undefined}
id={album.id}
itemType={LibraryItem.ALBUM}
src={imageUrl}
/>
<div className={styles.playButtonOverlay}>
<PlayButtonGroup onPlay={handlePlay} />
@@ -123,7 +131,13 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
<div className={styles.metadataSection}>
<Stack gap="sm">
{album.albumArtists?.[0] && (
<Text className={styles.artist} fw={500} size="md">
<Text
className={styles.artist}
fw={500}
lineClamp={1}
size="md"
ta="center"
>
{album.albumArtists[0].name}
</Text>
)}
@@ -201,28 +215,70 @@ export const FeatureCarousel = ({ data, onNearEnd }: FeatureCarouselProps) => {
}
}, [data, startIndex, itemsPerRow, onNearEnd]);
const handleNext = (e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: true };
setStartIndex((prev) => (prev + itemsPerRow) % data.length);
};
const handleNext = useCallback(
(e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: true };
setStartIndex((prev) => (prev + itemsPerRow) % data.length);
},
[data, itemsPerRow],
);
const handlePrevious = (e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: false };
setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length);
};
const handlePrevious = useCallback(
(e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
directionRef.current = { isNext: false };
setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length);
},
[data, itemsPerRow],
);
const canNavigate = data && data.length > itemsPerRow;
const wheelCooldownRef = useRef(0);
const wheelThreshold = 10;
const wheelCooldownMs = 250;
const handleWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!canNavigate || !data) {
return;
}
if (!event.shiftKey) {
return;
}
const now = Date.now();
const elapsed = now - wheelCooldownRef.current;
const horizontalDelta = Math.abs(event.deltaY);
if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {
return;
}
if (event.deltaY > 0) {
wheelCooldownRef.current = now;
handleNext();
} else if (event.deltaY < 0) {
wheelCooldownRef.current = now;
handlePrevious();
}
},
[canNavigate, data, handleNext, handlePrevious, wheelCooldownMs, wheelThreshold],
);
if (!data || data.length === 0) {
return null;
}
return (
<div className={styles.carouselContainer} ref={containerRef}>
<div className={styles.carouselContainer} onWheel={handleWheel} ref={containerRef}>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate="animate"
@@ -18,6 +18,7 @@ interface Card {
interface GridCarouselProps {
cards: Card[];
enableRefresh?: boolean;
hasNextPage?: boolean;
loadNextPage?: () => void;
onNextPage: (page: number) => void;
@@ -46,6 +47,7 @@ const pageVariants: Variants = {
function BaseGridCarousel(props: GridCarouselProps) {
const {
cards,
enableRefresh = false,
hasNextPage,
loadNextPage,
onNextPage,
@@ -155,45 +157,65 @@ function BaseGridCarousel(props: GridCarouselProps) {
{cq.isCalculated && (
<>
<div className={styles.navigation}>
<Group gap="xs" justify="space-between" w="100%">
<Group gap="xs">
{typeof title === 'string' ? (
{typeof title === 'string' ? (
<Group gap="xs" justify="space-between" w="100%">
<Group gap="xs">
<TextTitle fw={700} isNoSelect order={3}>
{title}
</TextTitle>
) : (
title
)}
{onRefresh && (
{enableRefresh && onRefresh && (
<ActionIcon
icon="refresh"
iconProps={{ size: 'xs' }}
onClick={onRefresh}
size="xs"
tooltip={{ label: 'Refresh' }}
variant="transparent"
/>
)}
</Group>
<Group gap="xs" justify="end">
<ActionIcon
icon="refresh"
iconProps={{ size: 'md' }}
onClick={onRefresh}
disabled={isPrevDisabled}
icon="arrowLeftS"
iconProps={{ size: 'lg' }}
onClick={handlePrevPage}
size="xs"
tooltip={{ label: 'Refresh' }}
variant="transparent"
variant="subtle"
/>
)}
<ActionIcon
disabled={isNextDisabled}
icon="arrowRightS"
iconProps={{ size: 'lg' }}
onClick={handleNextPage}
size="xs"
variant="subtle"
/>
</Group>
</Group>
<Group gap="xs" justify="end">
<ActionIcon
disabled={isPrevDisabled}
icon="arrowLeftS"
iconProps={{ size: 'lg' }}
onClick={handlePrevPage}
size="xs"
variant="subtle"
/>
<ActionIcon
disabled={isNextDisabled}
icon="arrowRightS"
iconProps={{ size: 'lg' }}
onClick={handleNextPage}
size="xs"
variant="subtle"
/>
</Group>
</Group>
) : (
<div className={styles.customTitleContainer}>
<div className={styles.customTitleContent}>{title}</div>
<Group gap="xs" justify="end">
<ActionIcon
disabled={isPrevDisabled}
icon="arrowLeftS"
iconProps={{ size: 'lg' }}
onClick={handlePrevPage}
size="xs"
variant="subtle"
/>
<ActionIcon
disabled={isNextDisabled}
icon="arrowRightS"
iconProps={{ size: 'lg' }}
onClick={handleNextPage}
size="xs"
variant="subtle"
/>
</Group>
</div>
)}
</div>
<AnimatePresence custom={currentPage} initial={false} mode="wait">
<motion.div
@@ -14,6 +14,19 @@
justify-content: space-between;
}
.custom-title-container {
display: flex;
gap: var(--theme-spacing-sm);
align-items: center;
justify-content: space-between;
width: 100%;
}
.custom-title-content {
flex: 1;
min-width: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
@@ -32,6 +32,7 @@ interface ItemCardControlsProps {
internalState?: ItemListStateActions;
item: Album | AlbumArtist | Artist | Playlist | Song | undefined;
itemType: LibraryItem;
showRating: boolean;
type?: 'compact' | 'default' | 'poster';
}
@@ -180,6 +181,7 @@ export const ItemCardControls = ({
internalState,
item,
itemType,
showRating,
type = 'default',
}: ItemCardControlsProps) => {
const playNowHandler = useMemo(
@@ -267,6 +269,7 @@ export const ItemCardControls = ({
<FavoriteButton isFavorite={isFavorite} onClick={favoriteHandler} />
)}
{controls?.onRating &&
showRating &&
(item?._serverType === ServerType.NAVIDROME ||
item?._serverType === ServerType.SUBSONIC) && (
<RatingButton
@@ -179,8 +179,7 @@
left: 0;
width: 100%;
padding: var(--theme-spacing-xs);
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
background-color: rgb(0 0 0 / 50%);
background-color: alpha(var(--theme-colors-background), 0.5);
backdrop-filter: blur(2px);
transform: translateY(0);
transition:
+100 -21
View File
@@ -1,12 +1,13 @@
import clsx from 'clsx';
import formatDuration from 'format-duration';
import { AnimatePresence } from 'motion/react';
import { Fragment, memo, ReactNode, useState } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './item-card.module.css';
import i18n from '/@/i18n/i18n';
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 { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import {
@@ -17,8 +18,16 @@ import {
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
import { Image } from '/@/shared/components/image/image';
import { useGeneralSettings } from '/@/renderer/store';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatDurationString,
formatRating,
} from '/@/renderer/utils/format';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
@@ -67,6 +76,7 @@ export const ItemCard = ({
type = 'poster',
withControls,
}: ItemCardProps) => {
const { showRatings } = useGeneralSettings();
const imageUrl = getImageUrl(data);
const rows = providedRows || [];
@@ -84,6 +94,7 @@ export const ItemCard = ({
isRound={isRound}
itemType={itemType}
rows={rows}
showRating={showRatings}
withControls={withControls}
/>
);
@@ -100,6 +111,7 @@ export const ItemCard = ({
isRound={isRound}
itemType={itemType}
rows={rows}
showRating={showRatings}
withControls={withControls}
/>
);
@@ -117,6 +129,7 @@ export const ItemCard = ({
isRound={isRound}
itemType={itemType}
rows={rows}
showRating={showRatings}
withControls={withControls}
/>
);
@@ -130,18 +143,20 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
imageUrl: string | undefined;
internalState?: ItemListStateActions;
rows: DataRow[];
showRating: boolean;
}
const CompactItemCard = ({
controls,
data,
enableDrag,
enableExpansion,
enableNavigation,
imageUrl,
internalState,
isRound,
itemType,
rows,
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
@@ -151,6 +166,53 @@ const CompactItemCard = ({
: undefined;
const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
drag: {
getId: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState);
return draggedItems.map((item) => item.id);
},
getItem: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState);
return draggedItems;
},
itemType,
onDragStart: () => {
if (!data) {
return;
}
const draggedItems = getDraggedItems(data, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTarget.ALBUM,
},
isEnabled: !!enableDrag && !!data,
});
const itemId = data && internalState ? data.id : undefined;
const isDraggingState = useItemDraggingState(internalState, itemId);
const isDragging = isDraggingState || isDraggingLocal;
const handleClick = useDoubleClick({
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
@@ -239,7 +301,7 @@ const CompactItemCard = ({
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = userRating !== null && userRating > 0;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
@@ -247,21 +309,25 @@ const CompactItemCard = ({
const imageContainerContent = (
<>
<Image
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
src={imageUrl}
id={data?.id}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={hasRating}
type="compact"
/>
)}
@@ -288,8 +354,10 @@ const CompactItemCard = ({
return (
<div
className={clsx(styles.container, styles.compact, {
[styles.dragging]: isDragging,
[styles.selected]: isSelected,
})}
ref={ref}
>
{enableNavigation && navigationPath && !internalState ? (
<Link
@@ -351,11 +419,11 @@ const DefaultItemCard = ({
data,
enableExpansion,
enableNavigation,
imageUrl,
internalState,
isRound,
itemType,
rows,
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
@@ -457,13 +525,15 @@ const DefaultItemCard = ({
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = userRating !== null && userRating > 0;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
<Image
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
id={data?.id}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
@@ -474,6 +544,7 @@ const DefaultItemCard = ({
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
showRating={showRating}
type="default"
/>
)}
@@ -563,11 +634,11 @@ const PosterItemCard = ({
enableDrag,
enableExpansion,
enableNavigation,
imageUrl,
internalState,
isRound,
itemType,
rows,
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
@@ -716,13 +787,15 @@ const PosterItemCard = ({
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = userRating !== null && userRating > 0;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
<Image
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
@@ -734,6 +807,7 @@ const PosterItemCard = ({
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="poster"
/>
)}
@@ -925,7 +999,7 @@ export const getDataRows = (): DataRow[] => {
{
format: (data) => {
if ('duration' in data && data.duration !== null) {
return formatDuration(data.duration * 1000);
return formatDurationString(data.duration);
}
return '';
},
@@ -943,7 +1017,7 @@ export const getDataRows = (): DataRow[] => {
{
format: (data) => {
if ('releaseDate' in data && data.releaseDate) {
return data.releaseDate;
return formatDateAbsoluteUTC(data.releaseDate);
}
return '';
},
@@ -961,7 +1035,12 @@ export const getDataRows = (): DataRow[] => {
{
format: (data) => {
if ('lastPlayedAt' in data && data.lastPlayedAt) {
return formatDateRelative(data.lastPlayedAt);
return (
<Group align="center" gap="xs">
<Icon icon="lastPlayed" size="sm" />
{formatDateRelative(data.lastPlayedAt)}
</Group>
);
}
return '';
},
@@ -970,7 +1049,7 @@ export const getDataRows = (): DataRow[] => {
{
format: (data) => {
if ('playCount' in data && data.playCount !== null) {
return String(data.playCount);
return i18n.t('entity.play', { count: data.playCount });
}
return '';
},
@@ -1019,7 +1098,7 @@ export const getDataRows = (): DataRow[] => {
{
format: (data) => {
if ('songCount' in data && data.songCount !== null) {
return String(data.songCount);
return i18n.t('entity.trackWithCount', { count: data.songCount });
}
return '';
},
@@ -1,146 +1,146 @@
import { AnimatePresence } from 'motion/react';
import { MouseEvent, useMemo, useState } from 'react';
import { Link } from 'react-router';
// import { AnimatePresence } from 'motion/react';
// import { MouseEvent, useMemo, useState } from 'react';
// import { Link } from 'react-router';
import styles from './item-detail.module.css';
// import styles from './item-detail.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { useFastAverageColor } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Rating } from '/@/shared/components/rating/rating';
import { Text } from '/@/shared/components/text/text';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { stringToColor } from '/@/shared/utils/string-to-color';
// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
// import { useFastAverageColor } from '/@/renderer/hooks';
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
// import { Badge } from '/@/shared/components/badge/badge';
// import { Divider } from '/@/shared/components/divider/divider';
// import { Group } from '/@/shared/components/group/group';
// import { Image } from '/@/shared/components/image/image';
// import { Rating } from '/@/shared/components/rating/rating';
// import { Text } from '/@/shared/components/text/text';
// import {
// Album,
// AlbumArtist,
// Artist,
// LibraryItem,
// Playlist,
// Song,
// } from '/@/shared/types/domain-types';
// import { stringToColor } from '/@/shared/utils/string-to-color';
interface ItemDetailProps {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
itemHeight: number;
itemType: LibraryItem;
onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
withControls?: boolean;
}
// interface ItemDetailProps {
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
// itemHeight: number;
// itemType: LibraryItem;
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
// withControls?: boolean;
// }
export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
const imageUrl = getImageUrl(data);
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
// const imageUrl = getImageUrl(data);
const [showControls, setShowControls] = useState(false);
// const [showControls, setShowControls] = useState(false);
const { background } = useFastAverageColor({
algorithm: 'simple',
src: imageUrl,
srcLoaded: false,
});
// const { background } = useFastAverageColor({
// algorithm: 'simple',
// src: imageUrl,
// srcLoaded: false,
// });
// const tags = [...(data?.genres ?? [])];
// // const tags = [...(data?.genres ?? [])];
const tags = useMemo(() => {
if (!data) {
return [];
}
// const tags = useMemo(() => {
// if (!data) {
// return [];
// }
const items: {
color?: string;
id: string;
isLight?: boolean;
itemType: LibraryItem;
name: string;
}[] = [];
// const items: {
// color?: string;
// id: string;
// isLight?: boolean;
// itemType: LibraryItem;
// name: string;
// }[] = [];
if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
data.albumArtists?.forEach((tag: { id: string; name: string }) => {
items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
});
}
// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
// });
// }
if ('genres' in data && Array.isArray(data.genres)) {
data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
const { color, isLight } = stringToColor(tag.name);
items.push({ ...tag, color, isLight });
});
}
// if ('genres' in data && Array.isArray(data.genres)) {
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
// const { color, isLight } = stringToColor(tag.name);
// items.push({ ...tag, color, isLight });
// });
// }
// if ('tags' in data && typeof data.tags === 'object') {
// console.log('data.tags :>> ', data.tags);
// Object.entries(data.tags).forEach(([key, value]) => {
// items.push({ id: key, itemType: LibraryItem.TAG, name: value });
// });
// }
// // if ('tags' in data && typeof data.tags === 'object') {
// // console.log('data.tags :>> ', data.tags);
// // Object.entries(data.tags).forEach(([key, value]) => {
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
// // });
// // }
return items;
}, [data]);
// return items;
// }, [data]);
return (
<div
className={styles.container}
onClick={(e) => onClick?.(e, data, itemType)}
style={{ backgroundColor: background }}
>
<div
className={styles.imageContainer}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
<Image alt={data?.name} src={imageUrl} />
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="compact" />}
</AnimatePresence>
</div>
<div className={styles.metadataContainer}>
<div className={styles.header}>
<Text className={styles.title} component={Link} isLink size="lg" weight={500}>
{data?.name}
</Text>
<Group>
{data && 'userRating' in data && (
<Rating size="xs" value={data?.userRating ?? 0} />
)}
{data && 'userFavorite' in data && (
<ActionIcon
icon="favorite"
iconProps={{
fill: data?.userFavorite ? 'primary' : 'default',
}}
size="xs"
/>
)}
</Group>
</div>
<Divider />
<div className={styles.content}>
<Group className={styles.tags} gap="xs">
{tags.map((tag) => (
<Badge
key={tag.id}
style={{
backgroundColor: tag.color,
color: tag.isLight ? 'black' : 'white',
}}
>
{tag.name}
</Badge>
))}
</Group>
</div>
</div>
</div>
);
};
// return (
// <div
// className={styles.container}
// onClick={(e) => onClick?.(e, data, itemType)}
// style={{ backgroundColor: background }}
// >
// <div
// className={styles.imageContainer}
// onMouseEnter={() => withControls && setShowControls(true)}
// onMouseLeave={() => withControls && setShowControls(false)}
// >
// <Image alt={data?.name} src={imageUrl} />
// <AnimatePresence>
// {withControls && showControls && <ItemCardControls type="compact" />}
// </AnimatePresence>
// </div>
// <div className={styles.metadataContainer}>
// <div className={styles.header}>
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
// {data?.name}
// </Text>
// <Group>
// {data && 'userRating' in data && (
// <Rating size="xs" value={data?.userRating ?? 0} />
// )}
// {data && 'userFavorite' in data && (
// <ActionIcon
// icon="favorite"
// iconProps={{
// fill: data?.userFavorite ? 'primary' : 'default',
// }}
// size="xs"
// />
// )}
// </Group>
// </div>
// <Divider />
// <div className={styles.content}>
// <Group className={styles.tags} gap="xs">
// {tags.map((tag) => (
// <Badge
// key={tag.id}
// style={{
// backgroundColor: tag.color,
// color: tag.isLight ? 'black' : 'white',
// }}
// >
// {tag.name}
// </Badge>
// ))}
// </Group>
// </div>
// </div>
// </div>
// );
// };
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) {
return data.imageUrl || undefined;
}
// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
// if (data && 'imageUrl' in data) {
// return data.imageUrl || undefined;
// }
return undefined;
};
// return undefined;
// };
@@ -0,0 +1,111 @@
import { memo, useMemo } from 'react';
import z from 'zod';
import { api } from '/@/renderer/api';
import {
GeneralSettingsSchema,
useAuthStore,
useCurrentServerId,
useSettingsStore,
} from '/@/renderer/store';
import { BaseImage, ImageProps } from '/@/shared/components/image/image';
import { LibraryItem } from '/@/shared/types/domain-types';
const getUnloaderIcon = (itemType: LibraryItem) => {
switch (itemType) {
case LibraryItem.ALBUM:
return 'emptyAlbumImage';
case LibraryItem.ALBUM_ARTIST:
return 'emptyArtistImage';
case LibraryItem.ARTIST:
return 'emptyArtistImage';
case LibraryItem.GENRE:
return 'emptyGenreImage';
case LibraryItem.PLAYLIST:
return 'emptyPlaylistImage';
case LibraryItem.SONG:
return 'emptySongImage';
default:
return 'emptyImage';
}
};
const BaseItemImage = (
props: Omit<ImageProps, 'src'> & {
id?: null | string;
itemType: LibraryItem;
src?: null | string;
},
) => {
const { src, ...rest } = props;
const imageUrl = useItemImageUrl({
id: props.id,
imageUrl: src,
itemType: props.itemType,
size: 300,
});
return <BaseImage src={imageUrl} unloaderIcon={getUnloaderIcon(props.itemType)} {...rest} />;
};
export const ItemImage = memo(BaseItemImage);
interface UseItemImageUrlProps {
id?: string;
imageUrl?: null | string;
itemType: LibraryItem;
serverId?: string;
size?: number;
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
}
export const useItemImageUrl = (args: UseItemImageUrlProps) => {
const { id, imageUrl, itemType, size, type } = args;
const serverId = useCurrentServerId();
const imageRes = useSettingsStore((store) => store.general.imageRes);
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
return useMemo(() => {
if (imageUrl) {
return imageUrl;
}
if (!id) {
return undefined;
}
return (
api.controller.getImageUrl({
apiClientProps: { serverId: args.serverId || serverId },
query: { id, itemType, size: size ?? sizeByType },
}) || undefined
);
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType]);
};
export function getItemImageUrl(args: UseItemImageUrlProps) {
const { id, imageUrl, itemType, size, type } = args;
const authStore = useAuthStore.getState();
const currentServerId = authStore.currentServer?.id;
const serverId = (args.serverId || currentServerId) as string;
const imageRes = useSettingsStore.getState().general.imageRes;
const sizeByType: number | undefined = type ? imageRes[type] : undefined;
if (imageUrl) {
return imageUrl;
}
if (!id) {
return undefined;
}
return (
api.controller.getImageUrl({
apiClientProps: { serverId },
query: { id, itemType, size: size ?? sizeByType },
}) || undefined
);
}
@@ -13,7 +13,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { LibraryItem } from '/@/shared/types/domain-types';
const getQueryKeyName = (itemType: LibraryItem): string => {
export const getListQueryKeyName = (itemType: LibraryItem): string => {
switch (itemType) {
case LibraryItem.ALBUM:
return 'albums';
@@ -115,7 +115,7 @@ export const useItemListInfiniteLoader = ({
return result;
},
queryKey: queryKeys[getQueryKeyName(itemType)].list(serverId, queryParams),
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams),
});
const endIndex = startIndex + itemsPerPage;
@@ -186,10 +186,9 @@ export const useItemListInfiniteLoader = ({
lastFetchedPageRef.current = -1;
currentVisibleRangeRef.current = null;
// Invalidate and wait for count query to refetch (this will suspend via useSuspenseQuery)
await queryClient.refetchQueries({
// Invalidate and wait for count query to refetch
await queryClient.ensureQueryData({
queryKey: countQueryKey,
type: 'active',
});
// Fetch the first page after count is refetched
@@ -0,0 +1,26 @@
import { useIsFetching } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { getListQueryKeyName } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
import { useCurrentServerId } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
export const useIsFetchingItemListCount = ({ itemType }: { itemType: LibraryItem }) => {
const serverId = useCurrentServerId();
const isFetching = useIsFetching({
queryKey: queryKeys[getListQueryKeyName(itemType)].count(serverId),
});
return isFetching > 0;
};
export const useIsFetchingItemList = ({ itemType }: { itemType: LibraryItem }) => {
const serverId = useCurrentServerId();
const isFetching = useIsFetching({
queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId),
});
return isFetching > 0;
};
@@ -1,4 +1,5 @@
.item-grid-container {
position: relative;
display: flex;
flex-direction: column !important;
width: 100%;
@@ -68,6 +68,7 @@ interface VirtualizedGridListProps {
outerRef: RefObject<any>;
ref: RefObject<FixedSizeList<GridItemProps> | null>;
rows?: ItemCardProps['rows'];
size?: 'compact' | 'default' | 'large';
tableMetaRef: RefObject<null | {
columnCount: number;
itemHeight: number;
@@ -95,6 +96,7 @@ const VirtualizedGridList = React.memo(
outerRef,
ref,
rows,
size,
tableMetaRef,
width,
}: VirtualizedGridListProps) => {
@@ -113,6 +115,7 @@ const VirtualizedGridList = React.memo(
internalState,
itemType,
rows,
size,
tableMeta,
};
}, [
@@ -126,6 +129,7 @@ const VirtualizedGridList = React.memo(
gap,
internalState,
itemType,
size,
]);
const handleOnScroll = useCallback(
@@ -215,7 +219,11 @@ const VirtualizedGridList = React.memo(
VirtualizedGridList.displayName = 'VirtualizedGridList';
const createThrottledSetTableMeta = (itemsPerRow?: number, rowsCount?: number) => {
const createThrottledSetTableMeta = (
itemsPerRow?: number,
rowsCount?: number,
size?: 'compact' | 'default' | 'large',
) => {
return throttle((width: number, dataLength: number, setTableMeta: (meta: any) => void) => {
const isSm = width >= 600;
const isMd = width >= 768;
@@ -228,11 +236,11 @@ const createThrottledSetTableMeta = (itemsPerRow?: number, rowsCount?: number) =
let dynamicItemsPerRow = 2;
if (is4xl) {
dynamicItemsPerRow = 12;
} else if (is3xl) {
dynamicItemsPerRow = 10;
} else if (is2xl) {
} else if (is3xl) {
dynamicItemsPerRow = 8;
} else if (is2xl) {
dynamicItemsPerRow = 7;
} else if (isXl) {
dynamicItemsPerRow = 6;
} else if (isLg) {
@@ -245,10 +253,22 @@ const createThrottledSetTableMeta = (itemsPerRow?: number, rowsCount?: number) =
dynamicItemsPerRow = 2;
}
if (size === 'large') {
dynamicItemsPerRow = Math.round(dynamicItemsPerRow * 0.75);
if (dynamicItemsPerRow < 1) {
dynamicItemsPerRow = 1;
}
}
const setItemsPerRow = itemsPerRow || dynamicItemsPerRow;
const widthPerItem = Number(width) / setItemsPerRow;
const itemHeight = widthPerItem + (rowsCount || getDataRowsCount()) * 26;
// For compact size, don't include text lines in height calculation
// CompactItemCard has a different layout that doesn't need the extra space
const itemHeight =
size === 'compact'
? widthPerItem
: widthPerItem + (rowsCount || getDataRowsCount()) * 26;
if (widthPerItem === 0) {
return;
@@ -273,6 +293,7 @@ export interface GridItemProps {
internalState: ItemListStateActions;
itemType: LibraryItem;
rows?: ItemCardProps['rows'];
size?: 'compact' | 'default' | 'large';
tableMeta: null | {
columnCount: number;
itemHeight: number;
@@ -286,6 +307,7 @@ export interface ItemGridListProps {
enableDrag?: boolean;
enableExpansion?: boolean;
enableSelection?: boolean;
enableSelectionDialog?: boolean;
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
getRowId?: ((item: unknown) => string) | string;
initialTop?: {
@@ -300,6 +322,7 @@ export interface ItemGridListProps {
overrideControls?: Partial<ItemControls>;
ref?: Ref<ItemListHandle>;
rows?: ItemCardProps['rows'];
size?: 'compact' | 'default' | 'large';
}
const BaseItemGridList = ({
@@ -319,6 +342,7 @@ const BaseItemGridList = ({
overrideControls,
ref,
rows,
size = 'default',
}: ItemGridListProps) => {
const rootRef = useRef(null);
const outerRef = useRef(null);
@@ -409,8 +433,8 @@ const BaseItemGridList = ({
}, [osInstance]);
const throttledSetTableMeta = useMemo(() => {
return createThrottledSetTableMeta(itemsPerRow, rows?.length);
}, [itemsPerRow, rows?.length]);
return createThrottledSetTableMeta(itemsPerRow, rows?.length, size);
}, [itemsPerRow, rows?.length, size]);
useLayoutEffect(() => {
const { current: container } = containerRef;
@@ -737,19 +761,23 @@ const BaseItemGridList = ({
outerRef={outerRef}
ref={listRef}
rows={rows}
size={size}
tableMetaRef={tableMetaRef}
width={width}
/>
)}
</AutoSizer>
<ExpandedContainer internalState={internalState} itemType={itemType} />
<AnimatePresence presenceAffectsLayout>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</AnimatePresence>
</motion.div>
);
};
const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
const { index, style } = props;
const { columns, controls, data, enableDrag, gap, itemType, rows } = props.data;
const { columns, controls, data, enableDrag, gap, itemType, rows, size } = props.data;
const items: ReactNode[] = [];
const itemCount = data.length;
@@ -780,6 +808,7 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
internalState={props.data.internalState}
itemType={itemType}
rows={rows}
type={size === 'compact' ? 'compact' : 'poster'}
withControls
/>
</div>,
@@ -3,6 +3,7 @@ import { useState } from 'react';
import styles from './image-column.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import {
ItemTableListInnerColumn,
TableColumnContainer,
@@ -14,17 +15,14 @@ import {
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => {
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[
props.columns[props.columnIndex].id
];
const playButtonBehavior = usePlayButtonBehavior();
const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any;
const playButtonBehavior = usePlayButtonBehavior();
const internalState = (props as any).internalState;
const [isHovered, setIsHovered] = useState(false);
@@ -80,12 +78,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Image
<ItemImage
containerClassName={clsx({
[styles.imageContainerWithAspectRatio]:
props.size === 'default' || props.size === 'large',
})}
src={row}
id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/>
{isHovered && (
<div
@@ -4,6 +4,7 @@ import { generatePath, Link } from 'react-router';
import styles from './title-combined-column.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import {
ColumnNullFallback,
@@ -19,13 +20,12 @@ import {
import { AppRoute } from '/@/renderer/router/routes';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id;
const item = props.data[props.rowIndex] as any;
const internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior();
@@ -74,8 +74,8 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
};
const artists = useMemo(() => {
if (row && 'artists' in row && Array.isArray(row.artists)) {
return (row.artists as RelatedAlbumArtist[]).map((artist) => {
if (row && 'artists' in item && Array.isArray(item.artists)) {
return (item.artists as RelatedAlbumArtist[]).map((artist) => {
const path = generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: artist.id,
});
@@ -83,9 +83,9 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
});
}
return [];
}, [row]);
}, [item, row]);
if (row && 'name' in row && 'imageUrl' in row && 'artists' in row) {
if (item && 'name' in item && 'imageUrl' in item && 'artists' in item) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
@@ -110,7 +110,12 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Image containerClassName={styles.image} src={row.imageUrl as string} />
<ItemImage
containerClassName={styles.image}
id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
@@ -138,7 +143,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
})}
>
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
{row.name as string}
{item.name as string}
</Text>
<div className={styles.artists}>
{artists.map((artist, index) => (
@@ -263,7 +268,12 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Image containerClassName={styles.image} src={row.imageUrl as string} />
<ItemImage
containerClassName={styles.image}
id={item?.id}
itemType={item?._itemType}
src={item?.imageUrl}
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
@@ -18,34 +18,34 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.ROW_INDEX,
width: 80,
width: 60,
},
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.IMAGE,
width: 70,
},
{
align: 'start',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.TITLE,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
isEnabled: true,
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.TITLE_COMBINED,
width: 300,
},
@@ -61,7 +61,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'start',
autoSize: false,
isEnabled: false,
isEnabled: true,
label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM,
@@ -70,7 +70,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'start',
autoSize: true,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_ARTIST,
@@ -115,7 +115,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.RELEASE_DATE,
@@ -178,7 +178,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.LAST_PLAYED,
@@ -214,7 +214,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DATE_ADDED,
@@ -232,7 +232,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PLAY_COUNT,
@@ -252,7 +252,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
},
@@ -268,9 +268,9 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
},
@@ -284,34 +284,34 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.ROW_INDEX,
width: 80,
width: 60,
},
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.IMAGE,
width: 70,
},
{
align: 'start',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.TITLE,
width: 300,
},
{
align: 'start',
autoSize: false,
isEnabled: false,
isEnabled: true,
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
pinned: 'left',
pinned: null,
value: TableColumn.TITLE_COMBINED,
width: 300,
},
@@ -327,7 +327,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'start',
autoSize: true,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ALBUM_ARTIST,
@@ -381,7 +381,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.RELEASE_DATE,
@@ -390,7 +390,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.LAST_PLAYED,
@@ -399,7 +399,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.DATE_ADDED,
@@ -408,7 +408,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.PLAY_COUNT,
@@ -419,7 +419,7 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
},
@@ -435,9 +435,9 @@ export const ALBUM_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
},
@@ -451,7 +451,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 80,
width: 60,
},
{
align: 'center',
@@ -539,7 +539,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
autoSize: false,
isEnabled: true,
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.USER_FAVORITE,
width: 60,
},
@@ -555,9 +555,9 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
},
@@ -571,7 +571,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 80,
width: 60,
},
{
align: 'center',
@@ -630,9 +630,9 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
},
@@ -646,7 +646,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
pinned: null,
value: TableColumn.ROW_INDEX,
width: 80,
width: 60,
},
{
align: 'start',
@@ -678,9 +678,9 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
{
align: 'center',
autoSize: false,
isEnabled: true,
isEnabled: false,
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
pinned: 'right',
pinned: null,
value: TableColumn.ACTIONS,
width: 60,
},
@@ -728,6 +728,15 @@ export const pickTableColumns = (options: {
const enabledSet = new Set(enabledColumns);
const remaining = columns.filter((col) => !enabledSet.has(col.value));
columnsToProcess = [...columnsToProcess, ...remaining];
} else {
// When pickColumns is provided, include pickColumns that aren't in enabledColumns
// so they can be added as disabled entries
const enabledSet = new Set(enabledColumns);
const pickColumnsNotEnabled = pickColumns
.filter((col) => !enabledSet.has(col))
.map((col) => columnMap.get(col))
.filter((col): col is DefaultTableColumn => col !== undefined);
columnsToProcess = [...columnsToProcess, ...pickColumnsNotEnabled];
}
} else {
columnsToProcess = columns;
@@ -1,4 +1,5 @@
.item-table-list-container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -672,6 +672,7 @@ interface ItemTableListProps {
enableHorizontalBorders?: boolean;
enableRowHoverHighlight?: boolean;
enableSelection?: boolean;
enableSelectionDialog?: boolean;
enableStickyGroupRows?: boolean;
enableStickyHeader?: boolean;
enableVerticalBorders?: boolean;
@@ -2318,6 +2319,7 @@ const BaseItemTableList = ({
totalRowCount={totalRowCount}
/>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
);
};
@@ -0,0 +1,22 @@
.selection-indicator {
position: absolute;
bottom: 0;
left: 50%;
z-index: 100;
min-width: 320px;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
color: var(--theme-colors-surface-foreground);
background: color-mix(in srgb, var(--theme-colors-surface) 85%, transparent);
border: 1px solid color-mix(in srgb, var(--theme-colors-border) 50%, transparent);
border-radius: var(--theme-radius-md);
box-shadow:
2px 2px 10px 2px rgb(0 0 0 / 40%),
0 0 0 1px rgb(255 255 255 / 5%);
backdrop-filter: blur(12px) saturate(180%);
transform: translateX(-50%);
}
.info-icon {
display: flex;
cursor: pointer;
}
@@ -0,0 +1,135 @@
import { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import styles from './selection-dialog.module.css';
import i18n from '/@/i18n/i18n';
import {
ItemListStateActions,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { Group } from '/@/shared/components/group/group';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Icon } from '/@/shared/components/icon/icon';
import { Kbd } from '/@/shared/components/kbd/kbd';
import { Table } from '/@/shared/components/table/table';
import { Text } from '/@/shared/components/text/text';
const controls = [
{
control1: <Kbd>CTRL</Kbd>,
control2: <Kbd>A</Kbd>,
label: i18n.t('action.selectAll', { postProcess: 'sentenceCase' }),
},
{
control1: <Kbd>CTRL</Kbd>,
control2: <Icon fill="default" icon="mouseLeftClick" />,
label: i18n.t('action.addOrRemoveFromSelection', { postProcess: 'sentenceCase' }),
},
{
control1: <Kbd>SHIFT</Kbd>,
control2: <Icon fill="default" icon="mouseLeftClick" />,
label: i18n.t('action.selectRangeOfItems', { postProcess: 'sentenceCase' }),
},
];
export const SelectionDialog = ({ internalState }: { internalState: ItemListStateActions }) => {
const { t } = useTranslation();
const isListExpanded = useItemListStateSubscription(internalState, (state) =>
state ? state.expanded.size > 0 : false,
);
const selectedCount = useItemListStateSubscription(internalState, (state) =>
state ? state.selected.size : 0,
);
const handleClearSelection = () => {
internalState.clearSelected();
};
const handleOpenMoreActions = (event: React.MouseEvent<unknown>) => {
event.preventDefault();
event.stopPropagation();
const selectedItems = internalState.getSelected();
if (selectedItems.length === 0) {
return;
}
ContextMenuController.call({
cmd: { items: selectedItems as any[], type: (selectedItems[0] as any)._itemType },
event,
});
};
const isOpen = selectedCount > 0;
return (
<AnimatePresence initial={false} mode="sync">
{isOpen && (
<motion.div
{...animationProps.fadeIn}
className={styles.selectionIndicator}
style={{ bottom: isListExpanded ? '320px' : '1rem' }}
>
<Group gap="xl" justify="space-between">
<Group gap="sm">
<HoverCard offset={20} position="top">
<HoverCard.Target>
<span className={styles.infoIcon}>
<Icon icon="keyboard" />
</span>
</HoverCard.Target>
<HoverCard.Dropdown>
<Table>
<Table.Tbody>
{controls.map((control) => (
<Table.Tr key={control.label}>
<Table.Td ta="start">
{control.control1}
</Table.Td>
<Table.Td>+</Table.Td>
<Table.Td ta="center">
{control.control2}
</Table.Td>
<Table.Td>
<Text size="xs">{control.label}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</HoverCard.Dropdown>
</HoverCard>
<Text fw={500} isNoSelect size="sm">
{t('common.countSelected', { count: selectedCount })}
</Text>
</Group>
<Group gap="xs">
<ActionIcon
icon="x"
iconProps={{ size: 'xl' }}
onClick={handleClearSelection}
size="xs"
variant="subtle"
/>
<ActionIcon
icon="ellipsisHorizontal"
iconProps={{ size: 'xl' }}
onClick={handleOpenMoreActions}
size="xs"
variant="subtle"
/>
</Group>
</Group>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -66,6 +66,7 @@ export interface ItemListComponentProps<TQuery> {
export interface ItemListGridComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
itemsPerRow?: number;
size?: 'compact' | 'default' | 'large';
}
export interface ItemListHandle {
@@ -6,6 +6,7 @@ import styles from './native-scroll-area.module.css';
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header/page-header';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { Platform } from '/@/shared/types/types';
interface NativeScrollAreaProps {
@@ -26,35 +27,31 @@ const BaseNativeScrollArea = forwardRef(
const { windowBarStyle } = useWindowSettings();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollHandlerRef = useRef<null | number>(null);
const scrollHandler = useThrottledCallback((e: Event) => {
if (noHeader || !pageHeaderProps) {
return;
}
const scrollElement = e?.target as HTMLDivElement;
if (!scrollElement || !containerRef.current) {
return;
}
const offset = pageHeaderProps.offset || 0;
const scrollTop = scrollElement.scrollTop;
if (scrollTop > offset) {
containerRef.current.setAttribute('data-scrolled', 'true');
} else {
containerRef.current.setAttribute('data-scrolled', 'false');
}
}, 100);
const [initialize] = useOverlayScrollbars({
defer: false,
events: {
scroll: (_instance, e) => {
if (scrollHandlerRef.current) {
cancelAnimationFrame(scrollHandlerRef.current);
}
scrollHandlerRef.current = requestAnimationFrame(() => {
if (noHeader || !pageHeaderProps) {
return;
}
const scrollElement = e?.target as HTMLDivElement;
if (!scrollElement || !containerRef.current) {
return;
}
const offset = pageHeaderProps.offset || 0;
const scrollTop = scrollElement.scrollTop;
if (scrollTop > offset) {
containerRef.current.setAttribute('data-scrolled', 'true');
} else {
containerRef.current.setAttribute('data-scrolled', 'false');
}
});
scrollHandler(e);
},
},
options: {
@@ -37,6 +37,18 @@
input {
-webkit-app-region: no-drag;
}
[role='button'] {
-webkit-app-region: no-drag;
}
a {
-webkit-app-region: no-drag;
}
[style*='cursor: pointer'] {
-webkit-app-region: no-drag;
}
}
.header.pad-right {
+5
View File
@@ -1,6 +1,11 @@
import { LibraryItem, Song } from '/@/shared/types/domain-types';
export type AutoDJQueueAddedEventPayload = {
songCount: number;
};
export type EventMap = {
AUTODJ_QUEUE_ADDED: AutoDJQueueAddedEventPayload;
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload;
MEDIA_NEXT: MediaNextEventPayload;
@@ -30,7 +30,7 @@ export const ServerRequired = () => {
const isServerLock = Boolean(window.SERVER_LOCK) || false;
if (Object.keys(serverList).length > 1) {
if (Object.keys(serverList).length > 0) {
return (
<ScrollArea>
<Stack miw="300px">
@@ -23,8 +23,8 @@ const NoNetworkRoute = () => {
return (
<AnimatedPage>
<PageHeader />
<Center style={{ height: '100%', width: '100vw' }}>
<Stack gap="xl" style={{ maxWidth: '50%', textAlign: 'center' }}>
<Center style={{ height: '100%' }}>
<Stack align="center" gap="xl" style={{ maxWidth: '50%', textAlign: 'center' }}>
<Icon icon="wifiOff" size="4rem" />
<Stack gap="md">
<Text size="xl" weight={600}>
@@ -42,6 +42,7 @@ import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import {
Album,
@@ -88,6 +89,12 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
items.push(...releaseTypes);
items.push(
{
id: 'isCompilation',
value: album?.isCompilation
? t('filter.isCompilation', { postProcess: 'sentenceCase' })
: undefined,
},
{
id: 'releaseDate',
value: album.releaseDate
@@ -96,7 +103,11 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
},
{
id: 'releaseYear',
value: album.releaseYear?.toString(),
value: album.releaseDate
? undefined
: album.releaseYear
? album.releaseYear.toString()
: undefined,
},
{
id: 'songCount',
@@ -136,19 +147,7 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
? t('common.clean', { postProcess: 'sentenceCase' })
: undefined,
},
{
id: 'isCompilation',
value: album?.isCompilation
? t('filter.isCompilation', { postProcess: 'sentenceCase' })
: undefined,
},
{
id: 'recordLabels',
value:
album.recordLabels && album.recordLabels.length > 0
? album.recordLabels.join(', ')
: undefined,
},
{
id: 'version',
value: album.version || undefined,
@@ -342,6 +341,7 @@ export const AlbumDetailContent = () => {
uniqueId: 'moreFromArtist',
},
{
enableRefresh: true,
excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined,
isHidden: !detailQuery?.data?.genres?.[0],
query: {
@@ -362,6 +362,9 @@ export const AlbumDetailContent = () => {
const comment = detailQuery?.data?.comment;
const releaseYear = detailQuery?.data?.releaseYear;
const labels = detailQuery?.data?.recordLabels;
const mbzId = detailQuery?.data?.mbzId;
return (
@@ -369,9 +372,7 @@ export const AlbumDetailContent = () => {
<div className={styles.detailContainer}>
{comment && (
<Spoiler maxHeight={75}>
<Text
dangerouslySetInnerHTML={{ __html: replaceURLWithHTMLLinks(comment) }}
/>
<Text pb="md">{replaceURLWithHTMLLinks(comment)}</Text>
</Spoiler>
)}
<div className={styles.contentLayout}>
@@ -396,7 +397,15 @@ export const AlbumDetailContent = () => {
</Stack>
</div>
</div>
{labels && (
<Stack gap="xs">
{labels.map((label) => (
<Text isMuted key={`label-${label}`} size="sm">
{releaseYear ? ` ${releaseYear}` : ''} {label}
</Text>
))}
</Stack>
)}
<Stack gap="lg" mt="3rem">
{cq.height || cq.width ? (
<Suspense fallback={<Spinner container />}>
@@ -404,6 +413,7 @@ export const AlbumDetailContent = () => {
.filter((c) => !c.isHidden)
.map((carousel) => (
<AlbumInfiniteCarousel
enableRefresh={carousel.enableRefresh}
excludeIds={carousel.excludeIds}
key={`carousel-${carousel.uniqueId}`}
query={carousel.query}
@@ -428,6 +438,7 @@ interface AlbumDetailSongsTableProps {
const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table);
const currentSong = usePlayerSong();
@@ -441,11 +452,11 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
const filteredSongs = useMemo(() => {
return sortSongList(
searchLibraryItems(songs, searchTerm, LibraryItem.SONG),
searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG),
sortBy,
sortOrder,
);
}, [songs, searchTerm, sortBy, sortOrder]);
}, [songs, debouncedSearchTerm, sortBy, sortOrder]);
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM_DETAIL,
@@ -493,7 +504,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
const groups = useMemo(() => {
// Remove groups when filtering
if (searchTerm.trim()) {
if (debouncedSearchTerm.trim()) {
return undefined;
}
@@ -579,7 +590,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
},
rowHeight: 40,
}));
}, [searchTerm, sortBy, discGroups, t]);
}, [debouncedSearchTerm, sortBy, discGroups, t]);
const player = usePlayer();
@@ -677,6 +688,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableStickyGroupRows
enableStickyHeader
enableVerticalBorders={tableConfig.enableVerticalBorders}
@@ -4,6 +4,7 @@ import { generatePath, Link, useParams } from 'react-router';
import styles from './album-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -12,7 +13,7 @@ import {
LibraryHeaderMenu,
} from '/@/renderer/features/shared/components/library-header';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
@@ -23,13 +24,15 @@ import { Play } from '/@/shared/types/types';
export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const { showRatings } = useGeneralSettings();
const detailQuery = useQuery(
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
);
const showRating =
detailQuery?.data?._serverType === ServerType.NAVIDROME ||
detailQuery?.data?._serverType === ServerType.SUBSONIC;
showRatings &&
(detailQuery?.data?._serverType === ServerType.NAVIDROME ||
detailQuery?.data?._serverType === ServerType.SUBSONIC);
const { addToQueueByFetch, setFavorite, setRating } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior();
@@ -82,10 +85,16 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
const releaseYear = detailQuery?.data?.releaseYear;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'header',
});
return (
<Stack ref={ref}>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
>
@@ -19,6 +19,7 @@ import {
import { ItemListKey } from '/@/shared/types/types';
interface AlbumCarouselProps {
enableRefresh?: boolean;
excludeIds?: string[];
query?: Partial<Omit<AlbumListQuery, 'startIndex'>>;
rowCount?: number;
@@ -28,7 +29,15 @@ interface AlbumCarouselProps {
}
const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
const { excludeIds, query: additionalQuery, rowCount = 1, sortBy, sortOrder, title } = props;
const {
enableRefresh,
excludeIds,
query: additionalQuery,
rowCount = 1,
sortBy,
sortOrder,
title,
} = props;
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
const {
data: albums,
@@ -81,6 +90,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
return (
<GridCarousel
cards={cards}
enableRefresh={enableRefresh}
hasNextPage={hasNextPage}
loadNextPage={fetchNextPage}
onNextPage={handleNextPage}
@@ -87,8 +87,9 @@ export const AlbumListView = ({
table,
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
const server = useCurrentServer();
const { pageKey } = useListContext();
const { query } = useAlbumListFilters();
const { query } = useAlbumListFilters(pageKey as ItemListKey);
const mergedQuery = useMemo(() => {
if (!overrideQuery) {
@@ -114,6 +115,7 @@ export const AlbumListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -125,6 +127,7 @@ export const AlbumListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
@@ -22,17 +23,13 @@ interface AlbumListHeaderProps {
}
export const AlbumListHeader = ({ title }: AlbumListHeaderProps) => {
const { itemCount } = useListContext();
return (
<Stack gap={0}>
<PageHeader>
<LibraryHeaderBar ignoreMaxWidth>
<PlayButton />
<PageTitle title={title} />
<LibraryHeaderBar.Badge isLoading={!itemCount}>
{itemCount}
</LibraryHeaderBar.Badge>
<AlbumListHeaderBadge />
</LibraryHeaderBar>
<Group>
<ListSearchInput />
@@ -45,6 +42,16 @@ export const AlbumListHeader = ({ title }: AlbumListHeaderProps) => {
);
};
const AlbumListHeaderBadge = () => {
const { itemCount } = useListContext();
const isFetching = useIsFetchingItemListCount({
itemType: LibraryItem.ALBUM,
});
return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;
};
const PageTitle = ({ title }: { title?: string }) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
@@ -27,6 +27,7 @@ export const AlbumListInfiniteGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: AlbumListInfiniteGridProps) => {
const listCountQuery = albumQueries.listCount({
query: { ...query },
@@ -65,6 +66,7 @@ export const AlbumListInfiniteGrid = ({
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
);
};
@@ -29,6 +29,7 @@ export const AlbumListPaginatedGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: AlbumListPaginatedGridProps) => {
const listCountQuery = albumQueries.listCount({
query: { ...query },
@@ -77,6 +78,7 @@ export const AlbumListPaginatedGrid = ({
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
</ItemListWithPagination>
);
@@ -6,6 +6,7 @@ import { Fragment, Suspense, useCallback, useRef } from 'react';
import styles from './expanded-album-list-item.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -197,10 +198,16 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
src: data?.imageUrl,
src: imageUrl,
srcLoaded: true,
});
@@ -300,7 +307,7 @@ export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumList
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${data?.imageUrl})`,
backgroundImage: `url(${imageUrl})`,
}}
/>
{data?.songs && data.songs.length > 0 && (
@@ -2,10 +2,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
MultiSelectWithInvalidData,
SelectWithInvalidData,
} from '/@/renderer/components/select-with-invalid-data';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
@@ -187,14 +184,14 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
searchable
/>
)}
<SelectWithInvalidData
<MultiSelectWithInvalidData
clearable
data={selectableAlbumArtists}
defaultValue={query.artistIds?.[0] || undefined}
defaultValue={query.artistIds || []}
disabled={disableArtistFilter}
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
onChange={(e) => setAlbumArtist(e ? [e] : null)}
onChange={(e) => (e && e.length > 0 ? setAlbumArtist(e) : setAlbumArtist(null))}
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable
/>
@@ -206,10 +203,10 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
interface TagFilterItemProps {
label: string;
onChange: (value: null | string) => void;
onChange: (value: null | string[]) => void;
options: Array<{ id: string; name: string }>;
tagValue: string;
value: string | undefined;
value: string | string[] | undefined;
}
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
@@ -222,15 +219,20 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI
[options],
);
const defaultValue = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
return (
<SelectWithInvalidData
<MultiSelectWithInvalidData
clearable
data={selectData}
defaultValue={value}
defaultValue={defaultValue}
key={tagValue}
label={label}
limit={100}
onChange={onChange}
onChange={(e) => (e && e.length > 0 ? onChange(e) : onChange(null))}
searchable
/>
);
@@ -257,7 +259,7 @@ const TagFilters = () => {
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string) => {
() => (tag: string, e: null | string[]) => {
setCustom({ [tag]: e });
},
[setCustom],
@@ -289,7 +291,7 @@ const TagFilters = () => {
onChange={(e) => handleTagFilter(tag.value, e)}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | undefined}
value={query._custom?.[tag.value] as string | string[] | undefined}
/>
))}
</>
@@ -15,13 +15,15 @@ import {
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useAlbumListFilters = () => {
export const useAlbumListFilters = (listKey?: ItemListKey) => {
const resolvedListKey = listKey ?? ItemListKey.ALBUM;
const { setSortBy, sortBy } = useSortByFilter<AlbumListSort>(
AlbumListSort.NAME,
ItemListKey.ALBUM,
resolvedListKey,
);
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.ALBUM);
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useLocation, useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
@@ -34,9 +35,16 @@ const AlbumDetailRoute = () => {
staleTime: 0,
});
const imageUrl =
useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
}) || '';
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
src: imageUrl,
srcLoaded: !detailQuery.isLoading,
});
@@ -45,7 +53,7 @@ const AlbumDetailRoute = () => {
const showBlurredImage = albumBackground;
const { isReady } = useWaitForColorCalculation({
hasImage: !!detailQuery.data?.imageUrl,
hasImage: !!imageUrl,
isLoading: isColorLoading,
routeId: albumId,
showBlurredImage,
@@ -81,7 +89,7 @@ const AlbumDetailRoute = () => {
<LibraryBackgroundImage
blur={albumBackgroundBlur}
headerRef={headerRef}
imageUrl={detailQuery.data?.imageUrl}
imageUrl={imageUrl}
/>
) : (
<LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />
@@ -7,6 +7,7 @@ import styles from './dummy-album-detail-route.module.css';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
@@ -113,12 +114,18 @@ const DummyAlbumDetailRoute = () => {
},
];
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'header',
});
return (
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
<LibraryContainer>
<Stack>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
loading={!background || colorId !== albumId}
title={detailQuery?.data?.name || ''}
@@ -212,11 +219,7 @@ const DummyAlbumDetailRoute = () => {
{comment && (
<section>
<Spoiler maxHeight={75}>
<Text
dangerouslySetInnerHTML={{
__html: replaceURLWithHTMLLinks(comment),
}}
/>
<Text pb="md">{replaceURLWithHTMLLinks(comment)}</Text>
</Spoiler>
</section>
)}
@@ -127,15 +127,19 @@ const getPlayerProperties = (): Pick<
const playbackSettings = useSettingsStore.getState().playback;
return {
'player.mediaSession': playbackSettings.mediaSession,
'player.mediaSession': ignoreWeb(playbackSettings.mediaSession),
'player.queueType': player.player.queueType,
'player.style': player.player.transitionType,
'player.transcoding': playbackSettings.transcode.enabled,
'player.type': playbackSettings.type,
'player.webAudio': playbackSettings.webAudio,
};
'player.type': ignoreWeb(playbackSettings.type),
'player.webAudio': ignoreWeb(playbackSettings.webAudio),
} as any;
};
function ignoreWeb<T>(value: T): T | undefined {
return isElectron() ? value : undefined;
}
const getSettingsProperties = (): SettingsProperties => {
const settings = useSettingsStore.getState();
@@ -148,22 +152,30 @@ const getSettingsProperties = (): SettingsProperties => {
'settings.autoDJItemCount': settings.autoDJ.itemCount,
'settings.autoDJTiming': settings.autoDJ.timing,
'settings.customCss': settings.css.enabled,
'settings.disableAutoUpdate': settings.window.disableAutoUpdate,
'settings.discord': settings.discord.enabled,
'settings.exitToTray': settings.window.exitToTray,
'settings.disableAutoUpdate': ignoreWeb(settings.window.disableAutoUpdate),
'settings.discord': ignoreWeb(settings.discord.enabled),
'settings.exitToTray': ignoreWeb(settings.window.exitToTray),
'settings.followSystemTheme': settings.general.followSystemTheme,
'settings.fontType': settings.font.type,
'settings.globalHotkeys': settings.hotkeys.globalMediaHotkeys,
'settings.homeFeature': settings.general.homeFeature,
'settings.language': settings.general.language,
// 'settings.lastFM': settings.general.lastFM,
'settings.lyrics.enableAutoTranslation': settings.lyrics.enableAutoTranslation,
'settings.lyrics.enableNeteaseTranslation': settings.lyrics.enableNeteaseTranslation,
'settings.lyrics.fetch': settings.lyrics.fetch,
'settings.lyrics.sources.genius': settings.lyrics.sources.includes(LyricSource.GENIUS),
'settings.lyrics.sources.lrclib': settings.lyrics.sources.includes(LyricSource.LRCLIB),
'settings.lyrics.sources.netease': settings.lyrics.sources.includes(LyricSource.NETEASE),
'settings.minimizeToTray': settings.window.minimizeToTray,
'settings.lyrics.enableAutoTranslation': ignoreWeb(settings.lyrics.enableAutoTranslation),
'settings.lyrics.enableNeteaseTranslation': ignoreWeb(
settings.lyrics.enableNeteaseTranslation,
),
'settings.lyrics.fetch': ignoreWeb(settings.lyrics.fetch),
'settings.lyrics.sources.genius': ignoreWeb(
settings.lyrics.sources.includes(LyricSource.GENIUS),
),
'settings.lyrics.sources.lrclib': ignoreWeb(
settings.lyrics.sources.includes(LyricSource.LRCLIB),
),
'settings.lyrics.sources.netease': ignoreWeb(
settings.lyrics.sources.includes(LyricSource.NETEASE),
),
'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),
// 'settings.musicBrainz': settings.general.musicBrainz,
'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
'settings.playerbarSliderType': settings.general.playerbarSlider
@@ -172,26 +184,26 @@ const getSettingsProperties = (): SettingsProperties => {
// 'settings.playerbarWaveformBarWidth': settings.general.playerbarSlider.barWidth,
// 'settings.playerbarWaveformGap': settings.general.playerbarSlider.barGap,
// 'settings.playerbarWaveformRadius': settings.general.playerbarSlider.barRadius,
'settings.preventSleepOnPlayback': settings.window.preventSleepOnPlayback,
'settings.releaseChannel': settings.window.releaseChannel,
'settings.preventSleepOnPlayback': ignoreWeb(settings.window.preventSleepOnPlayback),
'settings.releaseChannel': ignoreWeb(settings.window.releaseChannel),
'settings.resume': settings.general.resume,
'settings.scrobble.enabled': settings.playback.scrobble.enabled,
'settings.scrobble.notify': settings.playback.scrobble.notify,
'settings.scrobble.notify': ignoreWeb(settings.playback.scrobble.notify),
'settings.showLyricsInSidebar': settings.general.showLyricsInSidebar,
'settings.showVisualizerInSidebar': settings.general.showVisualizerInSidebar,
'settings.sideQueueType': settings.general.sideQueueType,
// 'settings.skipBackwardSeconds': settings.general.skipButtons.skipBackwardSeconds,
'settings.skipButtons': settings.general.skipButtons.enabled,
// 'settings.skipForwardSeconds': settings.general.skipButtons.skipForwardSeconds,
'settings.startMinimized': settings.window.startMinimized,
'settings.startMinimized': ignoreWeb(settings.window.startMinimized),
'settings.theme': settings.general.theme,
'settings.themeDark': settings.general.themeDark,
'settings.themeLight': settings.general.themeLight,
'settings.tray': settings.window.tray,
'settings.tray': ignoreWeb(settings.window.tray),
'settings.useThemeAccentColor': settings.general.useThemeAccentColor,
'settings.windowBarStyle': settings.window.windowBarStyle,
'settings.zoomFactor': settings.general.zoomFactor,
};
'settings.windowBarStyle': ignoreWeb(settings.window.windowBarStyle),
'settings.zoomFactor': ignoreWeb(settings.general.zoomFactor),
} as any;
};
const getServer = (): 'unknown' | ServerType => {
@@ -202,6 +214,7 @@ const getServer = (): 'unknown' | ServerType => {
export const useAppTracker = () => {
const { mutate: trackAppMutation } = useMutation(appTrackerMutation);
const { mutate: trackAppViewMutation } = useMutation(appViewMutation);
const hasRunOnMountRef = useRef(false);
useEffect(() => {
@@ -246,6 +259,10 @@ export const useAppTracker = () => {
meta: { properties, todayUTC },
});
trackAppViewMutation(undefined, {
onError: () => {},
});
trackAppMutation(properties, {
onError: () => {},
onSettled: () => {
@@ -275,9 +292,10 @@ export const useAppTracker = () => {
const interval = setInterval(checkAndTrack, 1000 * 60 * 60);
return () => clearInterval(interval);
}, [trackAppMutation]);
}, [trackAppMutation, trackAppViewMutation]);
};
// Sends the app event to the analytics server which includes usage data
const appTrackerMutation = mutationOptions({
mutationFn: (properties: AppTrackerProperties) => {
try {
@@ -296,3 +314,24 @@ const appTrackerMutation = mutationOptions({
retry: false,
throwOnError: false,
});
// Sends a view event to the analytics server which only includes language, screen, and website
// and triggers a page view event
const appViewMutation = mutationOptions({
mutationFn: () => {
try {
window.umami?.track((props) => ({
language: props.language,
screen: props.screen,
website: props.website,
}));
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
mutationKey: ['analytics', 'app-view'],
onSuccess: () => {},
retry: false,
throwOnError: false,
});
@@ -10,3 +10,57 @@
gap: var(--theme-spacing-2xl);
padding: 1rem 2rem 5rem;
}
.album-section-container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-4xl);
width: 100%;
min-width: 0;
}
.album-section-title {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--theme-spacing-md);
align-items: center;
margin-bottom: var(--theme-spacing-md);
}
.album-section-divider-container {
display: flex;
gap: var(--theme-spacing-md);
align-items: center;
width: 100%;
}
.album-section-divider {
flex: 1;
height: 2px;
background: var(--theme-colors-border);
}
.similar-artists-title {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--theme-spacing-md);
align-items: center;
width: 100%;
min-width: 0;
}
.album-grid {
display: flex;
flex-wrap: wrap;
gap: var(--theme-spacing-md);
width: 100%;
}
.album-grid-item {
flex: 1 1
calc((100% - (var(--items-per-row) - 1) * var(--theme-spacing-md)) / var(--items-per-row));
min-width: 0;
max-width: calc(
(100% - (var(--items-per-row) - 1) * var(--theme-spacing-md)) / var(--items-per-row)
);
}
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { forwardRef, Fragment, Ref } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
@@ -13,7 +14,7 @@ import {
LibraryHeaderMenu,
} from '/@/renderer/features/shared/components/library-header';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils';
import { Group } from '/@/shared/components/group/group';
@@ -29,17 +30,18 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
};
const routeId = (artistId || albumArtistId) as string;
const server = useCurrentServer();
const { showRatings } = useGeneralSettings();
const { t } = useTranslation();
const detailQuery = useQuery(
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
);
const albumCount = detailQuery?.data?.albumCount;
const songCount = detailQuery?.data?.songCount;
const duration = detailQuery?.data?.duration;
const albumCount = detailQuery.data?.albumCount;
const songCount = detailQuery.data?.songCount;
const duration = detailQuery.data?.duration;
const durationEnabled = duration !== null && duration !== undefined;
const metadataItems = [
@@ -66,62 +68,82 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
const { addToQueueByFetch, setFavorite, setRating } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = (type?: Play) => {
if (!server?.id || !routeId) return;
addToQueueByFetch(
server.id,
[routeId],
LibraryItem.ALBUM_ARTIST,
type || playButtonBehavior,
);
};
const handlePlay = useCallback(
(type?: Play) => {
if (!server?.id || !routeId) return;
addToQueueByFetch(
server.id,
[routeId],
LibraryItem.ALBUM_ARTIST,
type || playButtonBehavior,
);
},
[addToQueueByFetch, playButtonBehavior, routeId, server.id],
);
const handleFavorite = () => {
if (!detailQuery?.data) return;
const handleFavorite = useCallback(() => {
if (!detailQuery.data) return;
setFavorite(
detailQuery.data._serverId,
[detailQuery.data.id],
LibraryItem.ALBUM_ARTIST,
!detailQuery.data.userFavorite,
);
};
}, [detailQuery.data, setFavorite]);
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
const handleUpdateRating = useCallback(
(rating: number) => {
if (!detailQuery.data) return;
if (detailQuery.data.userRating === rating) {
return setRating(
detailQuery.data._serverId,
[detailQuery.data.id],
LibraryItem.ALBUM_ARTIST,
0,
);
}
if (detailQuery.data.userRating === rating) {
return setRating(
detailQuery.data._serverId,
[detailQuery.data.id],
LibraryItem.ALBUM_ARTIST,
0,
rating,
);
}
},
[detailQuery.data, setRating],
);
return setRating(
detailQuery.data._serverId,
[detailQuery.data.id],
LibraryItem.ALBUM_ARTIST,
rating,
);
};
const handleMoreOptions = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (!detailQuery.data) return;
ContextMenuController.call({
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },
event: e,
});
},
[detailQuery.data],
);
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!detailQuery?.data) return;
ContextMenuController.call({
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },
event: e,
});
};
const imageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME;
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => {
return detailQuery.data?.imageUrl || imageUrl;
}, [detailQuery.data?.imageUrl, imageUrl]);
return (
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
imageUrl={selectedImageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
ref={ref}
title={detailQuery?.data?.name || ''}
title={detailQuery.data?.name || ''}
>
<Stack gap="md" w="100%">
<Group className={styles.metadataGroup}>
@@ -135,13 +157,13 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
))}
</Group>
<LibraryHeaderMenu
favorite={detailQuery?.data?.userFavorite}
favorite={detailQuery.data?.userFavorite}
onFavorite={handleFavorite}
onMore={handleMoreOptions}
onPlay={(type) => handlePlay(type)}
onRating={showRating ? handleUpdateRating : undefined}
onShuffle={() => handlePlay(Play.SHUFFLE)}
rating={detailQuery?.data?.userRating || 0}
rating={detailQuery.data?.userRating || 0}
/>
</Stack>
</LibraryHeader>
@@ -31,6 +31,7 @@ export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
controls={controls}
data={albumArtist}
enableDrag
isRound
itemType={LibraryItem.ALBUM_ARTIST}
rows={rows}
type="poster"
@@ -94,6 +94,7 @@ export const AlbumArtistListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -105,6 +106,7 @@ export const AlbumArtistListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
@@ -18,7 +19,6 @@ interface AlbumArtistListHeaderProps {
export const AlbumArtistListHeader = ({ title }: AlbumArtistListHeaderProps) => {
const { t } = useTranslation();
const { itemCount } = useListContext();
const pageTitle = title || t('page.albumArtistList.title', { postProcess: 'titleCase' });
return (
@@ -27,9 +27,7 @@ export const AlbumArtistListHeader = ({ title }: AlbumArtistListHeaderProps) =>
<LibraryHeaderBar ignoreMaxWidth>
<PlayButton />
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={!itemCount}>
{itemCount}
</LibraryHeaderBar.Badge>
<AlbumArtistListHeaderBadge />
</LibraryHeaderBar>
<Group>
<ListSearchInput />
@@ -42,6 +40,16 @@ export const AlbumArtistListHeader = ({ title }: AlbumArtistListHeaderProps) =>
);
};
const AlbumArtistListHeaderBadge = () => {
const { itemCount } = useListContext();
const isFetching = useIsFetchingItemListCount({
itemType: LibraryItem.ALBUM_ARTIST,
});
return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;
};
const PlayButton = () => {
const { query } = useAlbumArtistListFilters();
@@ -28,6 +28,7 @@ export const AlbumArtistListInfiniteGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: AlbumArtistListInfiniteGridProps) => {
const listCountQuery = artistsQueries.albumArtistListCount({
query: { ...query },
@@ -65,6 +66,7 @@ export const AlbumArtistListInfiniteGrid = ({
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
);
};
@@ -30,6 +30,7 @@ export const AlbumArtistListPaginatedGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: AlbumArtistListPaginatedGridProps) => {
const listCountQuery = artistsQueries.albumArtistListCount({
query: { ...query },
@@ -77,6 +78,7 @@ export const AlbumArtistListPaginatedGrid = ({
itemType={LibraryItem.ALBUM_ARTIST}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
</ItemListWithPagination>
);
@@ -86,6 +86,7 @@ export const ArtistListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -97,6 +98,7 @@ export const ArtistListView = ({
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={mergedQuery}
serverId={server.id}
size={grid.size}
/>
);
}
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useIsFetchingItemListCount } from '/@/renderer/components/item-list/helpers/use-is-fetching-item-list';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters';
@@ -18,7 +19,6 @@ interface ArtistListHeaderProps {
export const ArtistListHeader = ({ title }: ArtistListHeaderProps) => {
const { t } = useTranslation();
const { itemCount } = useListContext();
const pageTitle = title || t('entity.artist_other', { postProcess: 'titleCase' });
return (
@@ -27,9 +27,7 @@ export const ArtistListHeader = ({ title }: ArtistListHeaderProps) => {
<LibraryHeaderBar ignoreMaxWidth>
<PlayButton />
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={!itemCount}>
{itemCount}
</LibraryHeaderBar.Badge>
<ArtistListHeaderBadge />
</LibraryHeaderBar>
<Group>
<ListSearchInput />
@@ -42,6 +40,16 @@ export const ArtistListHeader = ({ title }: ArtistListHeaderProps) => {
);
};
const ArtistListHeaderBadge = () => {
const { itemCount } = useListContext();
const isFetching = useIsFetchingItemListCount({
itemType: LibraryItem.ARTIST,
});
return <LibraryHeaderBar.Badge isLoading={isFetching}>{itemCount}</LibraryHeaderBar.Badge>;
};
const PlayButton = () => {
const { query } = useArtistListFilters();
@@ -27,6 +27,7 @@ export const ArtistListInfiniteGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: ArtistListInfiniteGridProps) => {
const listCountQuery = artistsQueries.artistListCount({
query: { ...query },
@@ -64,6 +65,7 @@ export const ArtistListInfiniteGrid = ({
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
);
};
@@ -29,6 +29,7 @@ export const ArtistListPaginatedGrid = ({
},
saveScrollOffset = true,
serverId,
size,
}: ArtistListPaginatedGridProps) => {
const listCountQuery = artistsQueries.artistListCount({
query: { ...query },
@@ -76,6 +77,7 @@ export const ArtistListPaginatedGrid = ({
itemType={LibraryItem.ARTIST}
onScrollEnd={handleOnScrollEnd}
rows={rows}
size={size}
/>
</ItemListWithPagination>
);
@@ -1,7 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useLocation, useParams } from 'react-router';
import { Suspense, useRef } from 'react';
import { useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
@@ -16,9 +17,10 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor, useWaitForColorCalculation } from '/@/renderer/hooks';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
const AlbumArtistDetailRoute = () => {
const AlbumArtistDetailRouteContent = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
@@ -31,18 +33,29 @@ const AlbumArtistDetailRoute = () => {
const routeId = (artistId || albumArtistId) as string;
const location = useLocation();
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),
);
const detailQuery = useSuspenseQuery({
...artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),
initialData: location.state?.item,
staleTime: 0,
const imageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'header',
});
const libraryBackgroundImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl;
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
id: artistId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
src: selectedImageUrl,
srcLoaded: true,
});
const background = backgroundColor;
@@ -50,14 +63,14 @@ const AlbumArtistDetailRoute = () => {
const showBlurredImage = artistBackground;
const { isReady } = useWaitForColorCalculation({
hasImage: !!detailQuery.data?.imageUrl,
hasImage: !!selectedImageUrl,
isLoading: isColorLoading,
routeId,
showBlurredImage,
});
if (!isReady) {
return null;
return <Spinner container />;
}
return (
@@ -73,7 +86,7 @@ const AlbumArtistDetailRoute = () => {
variant="default"
/>
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
{detailQuery.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
@@ -86,7 +99,7 @@ const AlbumArtistDetailRoute = () => {
<LibraryBackgroundImage
blur={artistBackgroundBlur}
headerRef={headerRef}
imageUrl={detailQuery.data?.imageUrl || ''}
imageUrl={libraryBackgroundImageUrl || ''}
/>
) : (
<LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />
@@ -100,6 +113,20 @@ const AlbumArtistDetailRoute = () => {
);
};
const AlbumArtistDetailRoute = () => {
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
};
const routeId = (artistId || albumArtistId) as string;
return (
<Suspense fallback={<Spinner container />} key={`album-artist-detail-suspense-${routeId}`}>
<AlbumArtistDetailRouteContent />
</Suspense>
);
};
const AlbumArtistDetailRouteWithBoundary = () => {
return (
<PageErrorBoundary>
@@ -127,6 +127,7 @@ const AlbumArtistDetailTopSongsListRoute = () => {
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableSelectionDialog={false}
enableVerticalBorders={tableConfig.enableVerticalBorders}
itemType={LibraryItem.SONG}
onColumnReordered={handleColumnReordered}
@@ -202,8 +202,12 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
}
if (allSongIds.length === 0) {
toast.warn({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
toast.success({
message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
postProcess: 'sentenceCase',
}),
});
return;
}
@@ -241,8 +245,12 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
}
if (songsToAdd.length === 0) {
toast.warn({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
toast.success({
message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
postProcess: 'sentenceCase',
}),
});
return;
}
@@ -0,0 +1,92 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
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, useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { AlbumArtist, Artist } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface PlayArtistRadioActionProps {
artist: AlbumArtist | Artist;
disabled?: boolean;
}
export const PlayArtistRadioAction = ({ artist, disabled }: PlayArtistRadioActionProps) => {
const { artistRadioCount } = useGeneralSettings();
const { t } = useTranslation();
const player = usePlayer();
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayArtistRadio = useCallback(
async (playType: Play) => {
if (!serverId || !artist) return;
try {
const artistRadioSongs = await queryClient.fetchQuery({
...songsQueries.artistRadio({
query: {
artistId: artist.id,
count: artistRadioCount,
},
serverId: serverId,
}),
queryKey: queryKeys.player.fetch({ artistId: artist.id }),
});
if (artistRadioSongs && artistRadioSongs.length > 0) {
player.addToQueueByData(artistRadioSongs, playType);
}
} catch (error) {
console.error('Failed to load track radio:', error);
}
},
[artist, artistRadioCount, player, queryClient, serverId],
);
const handlePlayArtistRadioNow = useCallback(() => {
handlePlayArtistRadio(Play.NOW);
}, [handlePlayArtistRadio]);
const handlePlayArtistRadioNext = useCallback(() => {
handlePlayArtistRadio(Play.NEXT);
}, [handlePlayArtistRadio]);
const handlePlayArtistRadioLast = useCallback(() => {
handlePlayArtistRadio(Play.LAST);
}, [handlePlayArtistRadio]);
const defaultPlayArtistRadioAction = useCallback(() => {
handlePlayArtistRadio(playButtonBehavior);
}, [handlePlayArtistRadio, playButtonBehavior]);
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
disabled={disabled}
leftIcon="radio"
onSelect={defaultPlayArtistRadioAction}
rightIcon="arrowRightS"
>
{t('player.artistRadio', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item leftIcon="mediaPlay" onSelect={handlePlayArtistRadioNow}>
{t('player.play', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayNext" onSelect={handlePlayArtistRadioNext}>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayLast" onSelect={handlePlayArtistRadioLast}>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,91 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
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 { 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;
song: Song;
}
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayTrackRadio = useCallback(
async (playType: Play) => {
if (!serverId || !song) return;
try {
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
songId: song.id,
},
serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: song.id }),
});
if (similarSongs && similarSongs.length > 0) {
player.addToQueueByData(similarSongs, playType);
}
} catch (error) {
console.error('Failed to load track radio:', error);
}
},
[player, queryClient, serverId, song],
);
const handlePlayTrackRadioNow = useCallback(() => {
handlePlayTrackRadio(Play.NOW);
}, [handlePlayTrackRadio]);
const handlePlayTrackRadioNext = useCallback(() => {
handlePlayTrackRadio(Play.NEXT);
}, [handlePlayTrackRadio]);
const handlePlayTrackRadioLast = useCallback(() => {
handlePlayTrackRadio(Play.LAST);
}, [handlePlayTrackRadio]);
const defaultPlayTrackRadioAction = useCallback(() => {
handlePlayTrackRadio(playButtonBehavior);
}, [handlePlayTrackRadio, playButtonBehavior]);
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
disabled={disabled}
leftIcon="radio"
onSelect={defaultPlayTrackRadioAction}
rightIcon="arrowRightS"
>
{t('player.trackRadio', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item leftIcon="mediaPlay" onSelect={handlePlayTrackRadioNow}>
{t('player.play', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayNext" onSelect={handlePlayTrackRadioNext}>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayLast" onSelect={handlePlayTrackRadioLast}>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};

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