Compare commits

...

118 Commits

Author SHA1 Message Date
jeffvli 9fe088174d remove discord-rpc import in renderer 2026-04-10 01:30:33 -07:00
jeffvli 515638c063 fix invalid dynamic imports in renderer 2026-04-10 01:20:52 -07:00
jeffvli 9b48671dcd increase node memory limit 2026-04-10 01:14:52 -07:00
jeffvli debec2e236 remove dynamic import for platform features 2026-04-10 01:07:30 -07:00
Kendall Garner 2a6e9b6ad3 add extendInfo to alpha/beta builders 2026-04-08 07:28:34 -07:00
Kendall Garner 167b42df2b Merge pull request #1926 from noctuum/fix/wayland-screen-share-dialog
fix(linux): remove unnecessary screen capture from audio loopback handler
2026-04-08 02:11:36 +00:00
jeffvli e6a2bc3acf disable useTransition in router again 2026-04-07 18:21:32 -07:00
jeffvli ca3c7015c6 add fallback to direct streamURL if getTranscodeDecision fails 2026-04-07 18:14:47 -07:00
jeffvli c7c15d917a isolate item card control renders 2026-04-07 18:14:47 -07:00
noctuum 6adb29bc38 fix(linux): remove unnecessary desktopCapturer call from display media handler
The setDisplayMediaRequestHandler was calling desktopCapturer.getSources()
to provide a video source that the renderer never uses (it requests
video: false and only consumes audio tracks). On Wayland, this created a
new xdg-desktop-portal ScreenCast session on every launch, showing an
unavoidable screen share dialog because Electron does not persist
PipeWire restore tokens across desktopCapturer sessions.

Simplified the handler to return only { audio: 'loopback' }, which
captures system audio via PipeWire/PulseAudio monitor source without
any portal interaction.
2026-04-08 04:56:05 +07:00
Hosted Weblate 2c3cd7af24 Translated using Weblate
Currently translated at 94.6% (1136 of 1200 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Zarakkas <kaz@users.noreply.hosted.weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-07 08:10:05 +02:00
jeffvli 3c442a2d40 update to v1.11.0 2026-04-06 17:10:18 -07:00
Andrzej Voss d67c185c93 feat: Make "Clear" button "Refresh" when there are no lyrics found. (#1920)
Ref: effvli/feishin#1919 - tl;dr: Button actually reloads/refreshes
lyrics info from the server too, it makes it, well, clearer what it does
in that case - allows to reread lyrics from server without clearing whole cache.
2026-04-06 16:59:01 -07:00
jeffvli ff96a5f121 lint 2026-04-06 12:06:55 -07:00
jeffvli 6fc7b6b271 support image drop for upload 2026-04-06 11:41:33 -07:00
jeffvli 918f453066 support navidrome artist image upload/delete 2026-04-06 11:41:26 -07:00
jeffvli 4a986069f8 set flac as default transcoding profile 2026-04-06 10:58:37 -07:00
jeffvli 11d26af893 remove arm/v7 from container build 2026-04-06 09:47:28 -07:00
jeffvli ad13fea033 update to v1.10.0 2026-04-05 22:41:06 -07:00
Hosted Weblate 8a75ec2558 Translated using Weblate
Currently translated at 100.0% (1196 of 1196 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-04-06 05:27:10 +00:00
Auzlex 895cbb4d16 fix(media-session): prevent handlers from being destroyed during playback (#1898)
Handlers were being registered and destroyed on state change/re-render,
causing media controls to vanish during rapid use or quick track skipping.
Persist handlers and add debounce for rapid track skipping.

Tested on Windows, Linux, and Android.
2026-04-05 22:27:04 -07:00
jeffvli 3f300c40cc add in-app prompt for system audio connection 2026-04-05 22:19:09 -07:00
jeffvli c8e8f58cce re-add useTransition to router 2026-04-05 21:54:07 -07:00
jeffvli 56cd50e0ed add react compiler 2026-04-05 18:38:06 -07:00
jeffvli 1b2a6dfc1f optimize item list controls 2026-04-05 18:21:28 -07:00
jeffvli 356f5487b0 reorder playlist context menu items 2026-04-05 14:10:07 -07:00
jeffvli 37501f2983 remove automatic autosize, use dummy fill column instead 2026-04-05 09:06:03 -07:00
jeffvli d61587b16f add automatic autosize columns when auto-fit is disabled 2026-04-05 08:12:10 -07:00
Kendall Garner 06b7b53dc9 feat(macos): add NSLocalNetworkUsageDescription 2026-04-05 08:03:28 -07:00
Kendall Garner 6c2cd1c274 fix(mpris): serve minimal metadata when playing radio
1. MPRIS (or `mpris-service`) is very fragile. If an invalid `mpris:trackid` (something with `-` or spaces) is passed in, it breaks. Use a minimal track id instead
2. Only populate album/artist/title
2026-04-05 08:01:28 -07:00
jeffvli ef129e4638 remove video from displayMedia request 2026-04-05 07:58:01 -07:00
jeffvli a01b4e664d add plex fork notice 2026-04-05 07:57:41 -07:00
jeffvli 0b45ab7f36 support real-time table column resizing 2026-04-05 07:48:54 -07:00
jeffvli 031d365262 decrease padding on list header 2026-04-05 03:49:31 -07:00
jeffvli 4fd56281d5 increase font size of smart playlist JSON editor 2026-04-05 02:42:05 -07:00
jeffvli 08ce8a4028 add nd v0.61.1 smart playlist fields 2026-04-05 02:38:40 -07:00
jeffvli e06877af76 make visualizer idle kill consistent for both 2026-04-05 02:35:32 -07:00
jeffvli 84395ce5b4 pass muted text props to JoinedArtists in left controls 2026-04-05 00:59:53 -07:00
jeffvli 94886a2d5a add system audio loopback for webaudio 2026-04-05 00:48:38 -07:00
jeffvli 25bb7f7069 fix scroll shadow z-indexing issue with table scrollbars 2026-04-04 23:48:48 -07:00
jeffvli 573fe5ee35 use external store for scroll shadow 2026-04-04 23:32:32 -07:00
jeffvli a868d4d539 combine wav codec check 2026-04-04 23:11:55 -07:00
jeffvli 564ee721c4 revert default transcoding profile to opus, add safari check for mp3 2026-04-04 23:08:53 -07:00
jeffvli a8d990db23 fix subsonic transcoding stream url to return raw string instead of fetch 2026-04-04 23:03:46 -07:00
jeffvli e21515f7fb add additional codec probes for transcoding profile, use mp3 instead of opus for default transcode 2026-04-04 23:03:29 -07:00
jeffvli 3e5a8ac78d re-add default suspense to album/artist routes 2026-04-04 22:25:21 -07:00
jeffvli 6c73d06dcf remove useTransition from router 2026-04-04 22:14:07 -07:00
jeffvli a8954bfa2a remove imageUrl in favor or imageId for artistInfo 2026-04-04 21:52:44 -07:00
jeffvli 19a1617a8d remove suspense spinner from router 2026-04-04 18:26:25 -07:00
jeffvli 1abae986f8 move server selector into app menu 2026-04-04 18:25:04 -07:00
jeffvli 43fa574dab add responsive breakpoint for queue control items 2026-04-04 17:37:47 -07:00
jeffvli 99530c670e redesign queue controls bar 2026-04-04 17:37:05 -07:00
jeffvli 3a0dfe59ce improve visibily of keyboard-focused search items 2026-04-04 17:37:05 -07:00
York d60ed0a793 feat: macOS menu enhancement (#1903) 2026-04-04 17:35:30 -07:00
Kendall Garner a32fed3bcf chore: upgrade dependencies (#1906)
* upgrade dependencies

* downgrade fast-average-color
2026-04-04 17:10:57 -07:00
Kendall Garner 132ac92984 chore: use consistent order of track / artist / ablum on full screen page 2026-04-04 14:46:13 -07:00
jeffvli 141a20f042 refactor item table props 2026-04-04 12:34:27 -07:00
jeffvli 1592204515 add fallback sort order for subsonic playlist list 2026-04-04 12:03:41 -07:00
jeffvli b9f5459725 fix layout shift on grid carousel page change 2026-04-03 20:25:12 -07:00
jeffvli d4e9b9b7a6 adjust bg loading on album detail page 2026-04-03 20:11:10 -07:00
jeffvli ec9e4b1339 fix type error due to new param on mediaStop 2026-04-03 19:09:42 -07:00
Hosted Weblate f09109b887 Translated using Weblate
Currently translated at 100.0% (1194 of 1194 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-04 04:05:50 +02:00
jeffvli 1494c8e044 fix mpv seek error on queue end 2026-04-03 19:05:34 -07:00
jeffvli f3a6027e6d fix mpv progress interval still running after queue ends 2026-04-03 18:58:58 -07:00
jeffvli 3c42355c1e attempt to fix mpv playback sync on song insertion (#1855) 2026-04-03 18:54:49 -07:00
jeffvli feda1bb06f remove square image param, default item id for image 2026-04-03 11:24:39 -07:00
jeffvli 72f1d2f9f9 improve date parsing for partial dates (#1683) 2026-04-02 19:39:08 -07:00
jeffvli ad11a9303c add playlist description to expanded header 2026-04-02 18:36:42 -07:00
jeffvli db06e7f601 add native nd radio endpoints, support radio station images 2026-04-02 18:26:26 -07:00
jeffvli fbf82c1ef0 add playlist image upload to edit playlist modal 2026-04-02 17:41:25 -07:00
jeffvli 92cea5dfda add log for direct play profiles 2026-04-02 01:27:14 -07:00
jeffvli 7442f9d3ca support navidrome playlist image upload 2026-04-02 01:23:09 -07:00
jeffvli 68dacea228 use resized images in artist header 2026-04-01 21:57:32 -07:00
jeffvli 51425b5e86 various performance refactors 2026-04-01 21:57:26 -07:00
jeffvli c60610cb42 lint files 2026-03-31 21:12:48 -07:00
jeffvli d3881ee3be support limitPercent for smart playlists 2026-03-31 21:09:13 -07:00
jeffvli de403ea6ac add new nd smart playlist fields
- averagerating

- albumdateloved
- albumlastplayed
- albumdaterated
- albumloved
- albumrating

- artistdateloved
 -artistlastplayed
- artistdaterated
- artistloved
- artistplaycount
2026-03-31 20:55:36 -07:00
jeffvli a30b1ec90b add OS transcoding extension 2026-03-31 20:45:22 -07:00
Hosted Weblate 7982c0e1bd Translated using Weblate
Currently translated at 100.0% (1193 of 1193 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Translated using Weblate

Currently translated at 83.4% (996 of 1193 strings) (Basque)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2026-03-31 16:09:57 +02:00
dependabot[bot] baf4e7bc0b Bump fast-xml-parser in the npm_and_yarn group across 1 directory (#1777)
Bumps the npm_and_yarn group with 1 update in the / directory: [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser).


Updates `fast-xml-parser` from 5.3.6 to 5.3.8
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.6...v5.3.8)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.3.8
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 21:43:31 -07:00
Sutaai 74c44558fe fix: window bar disappearing in Glassy Dark (#1878)
* fix: glassy dark content container claiming entire width  (#1713)

* fix: apply container height fix only when using window bar

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2026-03-29 21:43:08 -07:00
Pyx 4033619421 glassy dark theme improvements (#1844)
* glassy dark theme improvements
2026-03-29 21:27:56 -07:00
jeffvli 5d206bbb1f toggle fullscreen visualizer on left controls image (#1857) 2026-03-29 21:05:53 -07:00
Hosted Weblate 3db801f2de Translated using Weblate
Currently translated at 89.1% (1063 of 1193 strings) (German)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1193 of 1193 strings) (Japanese)
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

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

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: PhillyMay <mein.alias@outlook.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: linger <linger0517@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2026-03-30 05:34:25 +02:00
ebee04 0d3cf912d3 fix click on feishin icon in macos menu bar (tray) causing switch to feishin window (#1881) 2026-03-29 20:34:18 -07:00
jeffvli d81f30a8b5 disable lyrics on radio playback (#1885) 2026-03-29 20:33:31 -07:00
Kendall Garner a5c3b454f4 add flathub to readme 2026-03-28 21:33:51 -07:00
Kendall Garner 68e6e3cf65 feat(playlist): support updating playlist track order (#1875)
* feat(playlist): support updating playlist track order

* force track mode when editing

* use common confirmation for save

* remove en editPLaylist key
2026-03-27 21:36:08 -07:00
Romain VIGNERES 86e6b88555 feat(albums): show grouping tags on album detail page (#1872)
* feat(albums): show grouping tags on album detail page

---------

Co-authored-by: Romain VIGNERES <romain.vigneres@texa.fr>
2026-03-27 18:51:44 -07:00
jeffvli 5cdc45836f rework queue persistence (#1862) 2026-03-27 18:48:38 -07:00
Kendall Garner d438c802a4 fix(normalize): do not duplicate remixer when included in credit 2026-03-27 18:46:12 -07:00
Hosted Weblate a838bdebb7 Translated using Weblate
Currently translated at 79.5% (949 of 1193 strings) (Basque)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
2026-03-27 20:09:51 +01:00
Hosted Weblate 8ff2f4dfb4 Translated using Weblate
Currently translated at 100.0% (1193 of 1193 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-03-26 08:09:54 +01:00
jeffvli ede47fbf8f fix missing artist name in lyrics search query for lrclib (#1871) 2026-03-25 17:49:50 -07:00
jeffvli 9eb64079f7 handle disabled features in a single flag (#1271) 2026-03-25 17:41:08 -07:00
Hosted Weblate 3b955bb319 Translated using Weblate
Currently translated at 100.0% (1191 of 1191 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Translated using Weblate

Currently translated at 100.0% (1191 of 1191 strings) (Dutch)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/

Co-authored-by: bokse <weblate@bokse.nl>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2026-03-25 07:09:59 +01:00
Darius 816adfa6c7 Waveform playerbar improvements (#1781)
* Defer waveform loading & show default seek bar as fallback

* Add configurable waveform loading delay

* Add 2s default value for waveform loading delay

* disable transcoding config on waveform url

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-03-24 10:06:25 -07:00
Hosted Weblate f91dcc6af6 Translated using Weblate
Currently translated at 100.0% (1191 of 1191 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
2026-03-24 06:10:58 +01:00
kast3t 6dc58a3ff8 fix playlist sort by id (#1867) (#1868)
* fix playlist sort by id (#1867)
2026-03-23 18:27:21 -07:00
Kendall Garner 09fa10a4e9 fix(web): do not load umami if env is disabled 2026-03-22 15:08:21 -07:00
Hosted Weblate 6f45e1a814 Translated using Weblate
Currently translated at 100.0% (1191 of 1191 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: skajmer <skajmer@protonmail.com>
2026-03-21 23:09:51 +01:00
Hosted Weblate 62ba721f26 Translated using Weblate
Currently translated at 100.0% (1191 of 1191 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
2026-03-19 13:09:56 +01:00
jeffvli 67231753e4 add list search links to command palette 2026-03-18 02:51:27 -07:00
jeffvli c16eccaecb fix tab index on command palette play buttons 2026-03-18 02:17:31 -07:00
Hosted Weblate 0bdf1dcb75 Translated using Weblate
Currently translated at 100.0% (1190 of 1190 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-03-18 08:03:12 +00:00
jeffvli 598e9ca5c2 replace lastfm, musicbrainz, and listenbrainz logos 2026-03-18 01:02:57 -07:00
jeffvli 615f9c3515 refactor search into individual sections by itemtype, add infinite loader 2026-03-18 00:59:04 -07:00
jeffvli b7cbdb4d6c persist command palette collapsed sections to app store 2026-03-17 22:34:07 -07:00
jeffvli 3c562c1398 redesign command palette
- add collapsible search groups
- reduce modal padding
- reduce command item padding
- use breadcrumbs for pagination
- move page breadcrumbs to the bottom
2026-03-17 22:28:07 -07:00
jeffvli 3eafa73217 adjust padding / design of layout toggle 2026-03-17 21:40:16 -07:00
Hosted Weblate 74864d9621 Translated using Weblate
Currently translated at 100.0% (1184 of 1184 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Co-authored-by: York <goog10216922@gmail.com>
2026-03-18 04:25:29 +00:00
jeffvli cb5562d32e decrease height of single feature carousel (#1850) 2026-03-17 21:25:18 -07:00
jeffvli e40a175e12 add qobuz and listenbrainz external links 2026-03-17 21:10:31 -07:00
jeffvli f996b111b9 add new external brand icons 2026-03-17 21:10:31 -07:00
Hosted Weblate 0cb5c49924 Translated using Weblate
Currently translated at 100.0% (1180 of 1180 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
2026-03-18 02:06:56 +00:00
jeffvli c636029003 assert appstore state migration return type 2026-03-17 19:06:45 -07:00
jeffvli db88a6bc22 support vertical play queue layout 2026-03-17 19:01:01 -07:00
Hosted Weblate 8ccd97b574 Translated using Weblate
Currently translated at 100.0% (1180 of 1180 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Kristoffer <spinal-onto-rebel@duck.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: linger <linger0517@gmail.com>
2026-03-17 09:10:00 +01:00
252 changed files with 11806 additions and 5625 deletions
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+1
View File
@@ -24,6 +24,7 @@ jobs:
- name: Build and Publish releases
env:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
with:
+10 -2
View File
@@ -59,7 +59,11 @@ For media keys to work, you will be prompted to allow Feishin to be a Trusted Ac
#### Linux Notes
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin).
Alternatively, you can install it as an Appimage.
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments.
Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
Simply run the installer like this:
@@ -136,7 +140,7 @@ services:
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome` or `subsonic`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. When `SERVER_LOCK=true`, you can also set `LEGACY_AUTHENTICATION=true` or `LEGACY_AUTHENTICATION=false` to configure the legacy authentication flag for the server (only applicable for Subsonic/OpenSubsonic servers).
5. _Optional_ - If your server uses a separate public-facing URL than what integrating applications use internally to communicate with your server, such as a separate Navidrome `ShareURL`, set `REMOTE_URL` to said public-facing URL.
6. _Optional_ - To disable Umami analytics tracking in the Docker/web version, set the environment variable `ANALYTICS_DISABLED=true`. When enabled, the analytics script will not be loaded and all tracking will be disabled.
7. _Optional_ - App settings (theme, language, sidebar options, etc.) can be overridden with environment variables on first run. The variables use the `FS_` prefix (e.g. `FS_GENERAL_THEME=defaultDark`, `FS_GENERAL_LANGUAGE=de`). See [the settings environment variable documentation](docs/ENV_SETTINGS.md) for the full list.
@@ -165,6 +169,10 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
- [Qm-Music](https://github.com/chenqimiao/qm-music)
- More (?)
- [Plex](https://www.plex.tv/media-server-downloads)
- [Feishin fork by lux032](https://github.com/lux032/feishin) - Plex is not natively supported. Use the fork by lux032 to use Plex with Feishin.
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.
+3
View File
@@ -29,12 +29,14 @@ These variables override app settings **on first run** when no persisted setting
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. |
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 09 (number). |
| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. |
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
@@ -44,6 +46,7 @@ These variables override app settings **on first run** when no persisted setting
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use themes accent color instead of custom. |
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use themes primary shade. |
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |
+4 -3
View File
@@ -43,10 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -61,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
+4 -2
View File
@@ -43,9 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
publish:
+4 -2
View File
@@ -43,9 +43,11 @@ mac:
icon: assets/icons/icon.icns
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +62,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
afterAllArtifactBuild: scripts/after-all-artifact-build.mjs
+3 -2
View File
@@ -1,10 +1,11 @@
import react from '@vitejs/plugin-react';
import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path';
import conditionalImportPlugin from 'vite-plugin-conditional-import';
import dynamicImportPlugin from 'vite-plugin-dynamic-import';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { createReactPlugin } from './vite.react-plugin';
const currentOSEnv = process.platform;
const electronRendererTarget = 'chrome87';
@@ -64,7 +65,7 @@ const config: UserConfig = {
localsConvention: 'camelCase',
},
},
plugins: [react(), ViteEjsPlugin({ web: false })],
plugins: [createReactPlugin(), ViteEjsPlugin({ web: false })],
resolve: {
alias: {
'/@/i18n': resolve('src/i18n'),
+1 -1
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'react-refresh': eslintPluginReactRefresh,
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactHooks.configs['recommended-latest'].rules,
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
+78 -74
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.9.0",
"version": "1.11.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -31,36 +31,36 @@
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"postinstall": "electron-builder install-app-deps",
"lint": "pnpm run typecheck && pnpm run lint-code && pnpm run lint-styles",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"lint-code": "eslint --max-warnings=0 --cache .",
"lint-code:fix": "eslint --cache --fix .",
"lint-styles": "stylelint --max-warnings=0 'src/**/*.{css,scss}'",
"lint-styles:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:fix": "pnpm run lint-code:fix && pnpm run lint-styles:fix",
"package": "pnpm run build && electron-builder",
"package:dev": "pnpm run build && electron-builder --dir",
"package:linux": "pnpm run build && electron-builder --linux",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:linux:pr": "pnpm run build && electron-builder --linux --publish never",
"package:linux-arm64:pr": "pnpm run build && electron-builder --linux --arm64 --publish never",
"package:mac": "pnpm run build && electron-builder --mac",
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
"package:win": "pnpm run build && electron-builder --win",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
"publish:linux-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64",
"publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64",
"publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux",
"publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux",
"publish:mac": "pnpm run build && electron-builder --publish always --mac",
"publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac",
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
"publish:win": "pnpm run build && electron-builder --publish always --win",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"publish:win-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
"start": "electron-vite preview",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -68,119 +68,123 @@
"version": "pnpm version --no-git-tag-version",
"postversion": "node ./scripts/update-app-stream.mjs"
},
"resolutions": {
"react-router": "7.14.0",
"xml2js": "0.5.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^8.3.8",
"@mantine/core": "^8.3.8",
"@mantine/dates": "^8.3.8",
"@mantine/form": "^8.3.8",
"@mantine/hooks": "^8.3.8",
"@mantine/modals": "^8.3.8",
"@mantine/notifications": "^8.3.8",
"@mantine/colors-generator": "^8.3.18",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.90.9",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-persist-client": "^5.90.11",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2",
"@ts-rest/core": "^3.52.1",
"@wavesurfer/react": "^1.0.11",
"@xhayper/discord-rpc": "^1.3.0",
"audiomotion-analyzer": "^4.5.1",
"axios": "^1.13.5",
"butterchurn": "^3.0.0-beta.5",
"butterchurn-presets": "^3.0.0-beta.4",
"cheerio": "^1.1.2",
"@wavesurfer/react": "^1.0.12",
"@xhayper/discord-rpc": "^1.3.3",
"audiomotion-analyzer": "^4.5.4",
"axios": "^1.14.0",
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"cheerio": "^1.2.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"dompurify": "^3.3.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"fast-average-color": "^9.5.0",
"fast-xml-parser": "^5.3.6",
"electron-updater": "^6.8.3",
"fast-average-color": "9.5.0",
"fast-xml-parser": "^5.5.10",
"format-duration": "^3.0.2",
"fuse.js": "^7.1.0",
"i18next": "^25.6.2",
"fuse.js": "^7.2.0",
"i18next": "^25.10.10",
"icecast-metadata-stats": "^0.1.12",
"idb-keyval": "^6.2.2",
"immer": "^10.2.0",
"is-electron": "^2.2.2",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"md5": "^2.3.0",
"motion": "^12.23.24",
"motion": "^12.38.0",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.11",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"nuqs": "^2.7.1",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"qs": "^6.14.2",
"react": "^19.1.0",
"react-call": "^1.8.1",
"react-dom": "^19.1.0",
"qs": "^6.15.0",
"react": "^19.2.4",
"react-call": "^1.8.2",
"react-dom": "^19.2.4",
"react-error-boundary": "^5.0.0",
"react-i18next": "^16.3.3",
"react-icons": "^5.5.0",
"react-player": "^2.16.0",
"react-router": "^7.13.1",
"react-split-pane": "^3.0.4",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-player": "^2.16.1",
"react-router": "^7.14.0",
"react-split-pane": "^3.2.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.11",
"react-window-v2": "npm:react-window@^2.2.3",
"semver": "^7.5.4",
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.7.4",
"string-to-color": "^2.2.2",
"wavesurfer.js": "^7.11.1",
"ws": "^8.18.2",
"zod": "^3.22.3",
"zustand": "^5.0.5"
"wavesurfer.js": "^7.12.5",
"ws": "^8.20.0",
"zod": "^3.25.76",
"zustand": "^5.0.12"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/lodash": "^4.17.18",
"@types/md5": "^2.3.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/electron-localshortcut": "^3.1.3",
"@types/lodash": "^4.17.24",
"@types/md5": "^2.3.6",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^5.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.4.0",
"electron": "^39.8.6",
"electron-builder": "^26.8.2",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.1",
"eslint": "^9.24.0",
"eslint-plugin-perfectionist": "^4.13.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"i18next-parser": "^9.3.0",
"eslint-plugin-react-refresh": "^0.4.26",
"i18next-parser": "^9.4.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"stylelint": "^16.25.0",
"stylelint-config-css-modules": "^4.5.1",
"stylelint-config-recess-order": "^7.4.0",
"prettier": "^3.8.1",
"prettier-plugin-packagejson": "^2.5.22",
"stylelint": "^16.26.1",
"stylelint-config-css-modules": "^4.6.0",
"stylelint-config-recess-order": "^7.7.0",
"stylelint-config-standard": "^39.0.1",
"typescript": "^5.8.3",
"vite": "^7.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-conditional-import": "^0.1.7",
"vite-plugin-dynamic-import": "^1.6.0",
"vite-plugin-ejs": "^1.7.0",
"vite-plugin-pwa": "^1.1.0"
"vite-plugin-pwa": "^1.2.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+2444 -2412
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, normalizePath } from 'vite';
import { ViteEjsPlugin } from 'vite-plugin-ejs';
import { version } from './package.json';
import { createReactPlugin } from './vite.react-plugin';
export default defineConfig({
build: {
@@ -35,7 +35,7 @@ export default defineConfig({
},
},
plugins: [
react(),
createReactPlugin(),
ViteEjsPlugin({
prod: process.env.NODE_ENV === 'production',
root: normalizePath(path.resolve(__dirname, './src/remote')),
+3
View File
@@ -24,12 +24,14 @@ window.FS_GENERAL_HOME_FEATURE_STYLE = "${FS_GENERAL_HOME_FEATURE_STYLE}";
window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}";
window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}";
window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}";
window.FS_GENERAL_LISTEN_BRAINZ = "${FS_GENERAL_LISTEN_BRAINZ}";
window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}";
window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}";
window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}";
window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}";
window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}";
window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}";
window.FS_GENERAL_QOBUZ = "${FS_GENERAL_QOBUZ}";
window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}";
window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}";
window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
@@ -39,6 +41,7 @@ window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARE
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}";
window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}";
window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}";
+2 -2
View File
@@ -301,6 +301,7 @@
"forward": "endavant",
"manage": "gestiona",
"mbid": "ID de MusicBrainz",
"grouping": "agrupament",
"noResultsFromQuery": "la petició no ha produït resultats",
"path": "ruta",
"playerMustBePaused": "cal pausar el reproductor",
@@ -423,8 +424,7 @@
"editPlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) s'ha actualitzat amb èxit",
"title": "editar la $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada",
"editNote": "es recomana no editar manualment les llistes de reproducció grans. segur que accepteu el risc de perdre dades si sobreescriviu la llista de reproducció existent?"
"publicJellyfinNote": "Per algun motiu, Jellyfin no exposa si una llista de reproducció és pública o no. Si voleu que es mantingui pública, seleccioneu la següent entrada"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
+33 -6
View File
@@ -411,7 +411,21 @@
"autosave": "automaticky ukládat frontu přehrávání",
"autosave_description": "zapnout automatické ukládání fronty přehrávání na server. toto je možné pouze při použití Navidrome/Subsonic a není možné mít kombinovanou frontu přehrávání.",
"autosaveCount": "četnost automatického ukládání fronty přehrávání",
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby"
"autosaveCount_description": "kolik změn skladeb se může provést před uložením fronty. 1 (minimum) znamená při každé změně skladby",
"spotify_description": "na stránkách umělců a alb zobrazit odkazy na Spotify",
"spotify": "zobrazit odkazy na Spotify",
"nativeSpotify_description": "otevřít v aplikaci Spotify namísto vašeho prohlížeče",
"nativeSpotify": "použít aplikaci Spotify",
"listenbrainz_description": "na stránkách umělců a alb zobrazit odkazy na ListenBrainz",
"listenbrainz": "zobrazit odkazy na ListenBrainz",
"qobuz_description": "na stránkách umělců a alb zobrazit odkazy na Qobuz",
"qobuz": "zobrazit odkazy na Qobuz",
"sidePlayQueueLayout": "rozložení postranní fronty přehrávání",
"sidePlayQueueLayout_description": "nastaví rozložení postranní lišty přehrávání",
"sidePlayQueueLayout_optionHorizontal": "na šířku",
"sidePlayQueueLayout_optionVertical": "na výšku",
"waveformLoadingDelay": "zpoždění načítání vlnové křivky",
"waveformLoadingDelay_description": "zpoždění v sekundách před načtením vlnové křivky. zvyšte, pokud jste během používání webového přehrávače zaznamenali záseky."
},
"action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -433,7 +447,10 @@
"removeFromFavorites": "odebrat z $t(entity.favorite, {\"count\": 2})",
"openIn": {
"lastfm": "Otevřít v Last.fm",
"musicbrainz": "Otevřít v MusicBrainz"
"musicbrainz": "Otevřít v MusicBrainz",
"spotify": "Otevřít na Spotify",
"listenbrainz": "Otevřít ve službě ListenBrainz",
"qobuz": "Otevřít ve službě Qobuz"
},
"moveToNext": "přesunout na další",
"downloadStarted": "spuštěno stahování {{count}} položek",
@@ -579,7 +596,9 @@
"filter_single": "jeden",
"filter_multiple": "několik",
"rename": "přejmenovat",
"newVersionAvailable": "je dostupná nová verze"
"newVersionAvailable": "je dostupná nová verze",
"numberOfResults": "{{numberOfResults}} výsledků",
"grouping": "seskupování"
},
"table": {
"config": {
@@ -1045,8 +1064,7 @@
"editPlaylist": {
"title": "upravit $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) úspěšně aktualizován",
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup",
"editNote": "ruční úpravy velkých seznamů skladeb nejsou doporučeny. opravdu přijímáte riziko ztráty dat, které může vzniknout přepsáním existujícího seznamu skladeb?"
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup"
},
"shareItem": {
"allowDownloading": "umožnit stahování",
@@ -1092,6 +1110,9 @@
"export": "exportovat texty",
"input_synced": "exportovat synchronizované texty",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stanice rádia úspěšně upravena"
}
},
"entity": {
@@ -1360,6 +1381,12 @@
}
},
"pasteGradient": "Vložit přechod",
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…"
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…",
"systemAudioConsentAllow": "Povolit",
"systemAudioConsentBody": "Vizualizér potřebuje pro svou činnost přístup k systémovému zvuku",
"systemAudioConsentTitle": "Povolit přístup k systémovému zvuku?",
"systemAudioCaptureFailed": "Nepodařilo se spustit zachytávání: {{message}}",
"systemAudioNoAudioTrack": "Nebyla zachycena žádná zvuková stopa. Ujistěte se, že jste při výzvě povolili zachytávání zvuku.",
"systemAudioConsentDecline": "Zamítnout"
}
}
-1
View File
@@ -359,7 +359,6 @@
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin viser af en eller anden grund ikke, om en playliste er offentlig eller ej. Hvis du ønsker, at den forbliver offentlig, skal du have følgende felt markeret",
"editNote": "manuelle ændringer anbefales ikke for store playlister. er du sikker på, at du accepterer risikoen for datatab ved at overskrive den eksisterende playliste?",
"success": "$t(entity.playlist, {\"count\": 1}) opdateret",
"title": "rediger $t(entity.playlist, {\"count\": 1})"
},
+59 -31
View File
@@ -19,7 +19,10 @@
"removeFromFavorites": "Aus $t(entity.favorite, {\"count\": 2}) entfernen",
"openIn": {
"lastfm": "Auf Last.fm öffnen",
"musicbrainz": "Auf MusicBrainz öffnen"
"musicbrainz": "Auf MusicBrainz öffnen",
"listenbrainz": "In ListenBrainz öffnen",
"qobuz": "In Qobuz öffnen",
"spotify": "In Spotify öffnen"
},
"moveToNext": "Als nächstes",
"downloadStarted": "Download von {{count}} Elementen gestartet",
@@ -124,6 +127,7 @@
"preview": "Vorschau",
"reload": "Neu Laden",
"mbid": "MusicBrainz ID",
"grouping": "gruppierung",
"close": "schließen",
"share": "Teilen",
"translation": "Übersetzung",
@@ -162,7 +166,8 @@
"filter_single": "einzeln",
"filter_multiple": "mehrfach",
"retry": "Wiederholen",
"newVersionAvailable": "Eine neue Version ist verfügbar"
"newVersionAvailable": "Eine neue Version ist verfügbar",
"numberOfResults": "{{numberOfResults}} Ergebnisse"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -237,7 +242,7 @@
"criticRating": "Kritikerbewertung",
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "Track",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
@@ -268,13 +273,13 @@
"input_name": "Servername",
"success": "Server erfolgreich hinzugefügt",
"input_savePassword": "Passwort speichern",
"ignoreSsl": "SSL ignorieren $t(common.restartRequired)",
"ignoreCors": "CORS ignorieren $t(common.restartRequired)",
"ignoreSsl": "SSL ignorieren ($t(common.restartRequired))",
"ignoreCors": "CORS ignorieren ($t(common.restartRequired))",
"error_savePassword": "Beim Speichern des Passworts ist ein Fehler aufgetreten",
"input_preferInstantMix": "Instant-Mix bevorzugen",
"input_preferInstantMixDescription": "nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen",
"input_preferRemoteUrl": "öffentliche URL bevorzugen",
"input_remoteUrl": "Öffentliche URL",
"input_remoteUrl": "öffentliche URL",
"input_remoteUrlPlaceholder": "Optional: öffentliche URL für externe Funktionen"
},
"addToPlaylist": {
@@ -301,8 +306,7 @@
"editPlaylist": {
"title": "Bearbeite $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) erfolgreich aktualisiert",
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus",
"editNote": "Manuelles Bearbeiten wird für große Wiedergabelisten nicht empfohlen. Bist Du sicher, dass Du die aktuelle Wiedergabeliste unter dem Risiko von Datenverlust überschrieben möchtest?"
"publicJellyfinNote": "Jellyfin legt aus irgendwelchen Gründen nicht offen ob eine Wiedergabeliste öffentlich ist oder nicht. Wenn du möchtest, dass sie öffentlich bleibt, wähle bitte diese Option aus"
},
"lyricSearch": {
"title": "Songtext Suche",
@@ -353,6 +357,9 @@
"input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren",
"input_synced": "Synchronisierte Songtexte exportieren"
},
"editRadioStation": {
"success": "Radiosender erfolgreich aktualisiert"
}
},
"entity": {
@@ -422,8 +429,8 @@
"pagination": "Seitenzahlen",
"pagination_itemsPerPage": "Elemente pro Seite",
"pagination_infinite": "unendlich",
"moveUp": "Nach oben bewegen",
"moveDown": "Nach unten bewegen",
"moveUp": "nach oben",
"moveDown": "nach unten",
"pinToLeft": "links anheften",
"pinToRight": "rechts anheften",
"itemGap": "Item Abstand (px)",
@@ -446,13 +453,13 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)",
"actions": "$t(common.action,{\"count\":2})",
"genre": "$t(entity.genre, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})",
"size": "$t(common.size)",
"bpm": "$t(common.bpm)",
"titleCombined": "$t(common.title) (kombiniert)",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"duration": "$t(common.duration)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
@@ -489,7 +496,7 @@
"rating": "Bewertung",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel,{\"count\":2})",
"comment": "Kommentar",
"dateAdded": "hinzugefügt am",
"playCount": "Abgespielt",
@@ -712,7 +719,8 @@
},
"releasenotes": {
"commitsSinceStable": "Commits seit {{stable}}",
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar"
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar",
"noNewCommits": "keine neuen Beiträge in diesem Bereich"
}
},
"player": {
@@ -762,12 +770,12 @@
"sleepTimer": "Sleep Timer",
"sleepTimer_custom": "Benutzerdefiniert",
"sleepTimer_hours": "{{count}} std",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_minutes": "{{count}} Min",
"trackRadio": "Song Radio",
"albumRadio": "Album Radio"
},
"setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
"audioDevice_description": "das für die Wiedergabe zu verwendende Audiogerät auswählen",
"audioExclusiveMode": "Audio-Exklusivmodus",
"audioDevice": "Audiogerät",
"accentColor": "Akzentfarbe",
@@ -785,7 +793,7 @@
"crossfadeDuration": "Dauer der Überblendung",
"discordIdleStatus": "rich presence status im Leerlauf",
"audioPlayer": "Audio-Player",
"discordApplicationId": "{{discord}} Anwendungs ID",
"discordApplicationId": "{{discord}} Anwendungs-ID",
"customFontPath_description": "Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll",
"remotePort_description": "Legt den Port des Fernsteuerungsserver fest",
"hotkey_skipBackward": "rückwärts springen",
@@ -1005,7 +1013,7 @@
"transcodeFormat": "Format für Umwandlung",
"startMinimized_description": "Startet die Anwendung im Info-Bereich",
"startMinimized": "Im Info-Bereich starten",
"mediaSession_description": "Aktiviert die Windows Media Session-Integration, zeigt Mediensteuerelemente und Metadaten im Systemlautstärke-Overlay und auf dem Sperrbildschirm an (nur Windows)",
"mediaSession_description": "aktiviert die Media Session Integration. Dies ermöglicht die Steuerung und Anzeige der Medien in der Systemlautstärkeoption und auf dem Sperrbildschirm",
"mediaSession": "Media Session aktivieren",
"artistBackgroundBlur": "Unschärfegrad für Künstlerhintergründe",
"artistBackgroundBlur_description": "Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird",
@@ -1013,11 +1021,11 @@
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
"crossfadeStyle": "Art der Überblende",
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt",
"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",
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (nicht zulässig sind z. B. \"url()\" und \"content:\"), kann ein benutzerdefiniertes CSS Risiken mit sich bringen, da die Benutzeroberfläche dadurch verändert wird",
"releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Stabil",
"releaseChannel": "Veröffentlichungskanal",
"releaseChannel_description": "Zwischen stabilen und beta Veröffentlichungen für automatische Aktualisierungen wählen",
"releaseChannel_description": "zwischen stabilen, Beta- oder Alpha-Versionen (Nightly) für automatische Updates wählen",
"discordDisplayType_artistname": "Künstlername(n)",
"discordDisplayType_description": "Ändert den aktuellen Titel im Zuhör-Status",
"discordDisplayType_songname": "Songtitel",
@@ -1108,7 +1116,22 @@
"useThemeAccentColor_description": "Verwendet die Primärfarbe des gewählten Themas anstatt einer ausgewählten Akzentfarbe",
"useThemePrimaryShade": "Primärschatten des Themas nutzen",
"useThemePrimaryShade_description": "Verwendet den Primärschatten des ausgewählten Themas als primäre Farbvarianten",
"primaryShade": "Primärschatten"
"primaryShade": "Primärschatten",
"listenbrainz": "ListenBrainz Links anzeigen",
"listenbrainz_description": "Zeige Links zu ListenBrainz auf den Interpreten/Alben Seiten",
"mpvExtraParameters": "Zusätzliche mpv Parameter",
"qobuz": "Qobuz Links anzeigen",
"spotify": "Spotify Links anzeigen",
"nativeSpotify": "Spotify App benutzen",
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung",
"discordStateIcon": "Play Icon anzeigen",
"homeFeatureStyle_optionSingle": "Einzeln",
"nativeSpotify_description": "in der Spotify App statt im Browser öffnen",
"imageResolution_optionFullScreenPlayer": "Wiedergabe im Vollbildmodus",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertikal"
},
"dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
@@ -1198,7 +1221,7 @@
"dualVertical": "Dual-Vertikal"
}
},
"minimumFrequency": "Mindestfrequenz",
"minimumFrequency": "Minimale Frequenz",
"minimumDecibels": "Minimale Dezibel",
"visualizerType": "Visualizer Art",
"cyclePresets": "Vorlagen durchrotieren",
@@ -1233,10 +1256,10 @@
"channelLayout": "Kanallayout",
"maxFPS": "Max FPS",
"opacity": "Deckkraft",
"customGradients": "Benutzerdefinierte Gradienten",
"customGradients": "Benutzerdefinierter Farbverlauf",
"addCustomGradient": "Benutzerdefinierten Gradienten hinzufügen",
"gradientName": "Gradientenname",
"gradientNamePlaceholder": "Gradientenname",
"gradientName": "Name Farbverlauf",
"gradientNamePlaceholder": "Name Farbverlauf",
"vertical": "Vertikal",
"horizontal": "Horizontal",
"addColor": "Farbe hinzufügen",
@@ -1249,9 +1272,9 @@
"builtIn": "Eingebaut",
"colors": "Farben",
"colorMode": "Farbmodus",
"gradient": "Gradienten",
"gradientLeft": "Gradienten links",
"gradientRight": "Gradienten rechts",
"gradient": "Farbverlauf",
"gradientLeft": "Farberverlauf links",
"gradientRight": "Farbverlauf rechts",
"fft": "FFT",
"fftSize": "FFT Größe",
"smoothing": "Glätten",
@@ -1260,15 +1283,20 @@
"sensitivity": "Empfindlichkeit",
"weightingFilter": "Gewichtungsfilter",
"maximumDecibels": "Maximale Dezibel",
"linearAmplitude": "Lineare Amplitude",
"linearAmplitude": "Linearer Ausschlag",
"linearBoost": "Linearer Boost",
"radialSpectrum": "Radiales Spektrum",
"radial": "Radial",
"radialInvert": "Radial invertiert",
"radius": "Radius",
"miscellaneousSettings": "Verschiedenes Einstellungen",
"miscellaneousSettings": "Verschiedene Einstellungen",
"ansiBands": "ANSI Bänder",
"lowResolution": "Niedrige Auflösung",
"showFPS": "FPS anzeigen"
"showFPS": "FPS anzeigen",
"fadePeaks": "Spitzen abblenden",
"showPeaks": "Spitzen anzeigen",
"systemAudioConsentAllow": "Erlauben",
"systemAudioConsentDecline": "Ablehnen",
"frequencyScale": "Frequenzskala"
}
}
+23 -1
View File
@@ -37,7 +37,9 @@
"openApplicationDirectory": "open application directory",
"openIn": {
"lastfm": "Open in Last.fm",
"listenbrainz": "Open in ListenBrainz",
"musicbrainz": "Open in MusicBrainz",
"qobuz": "Open in Qobuz",
"spotify": "Open in Spotify"
}
},
@@ -107,11 +109,13 @@
"minimize": "minimize",
"modified": "modified",
"mbid": "MusicBrainz ID",
"grouping": "grouping",
"mood": "mood",
"name": "name",
"no": "no",
"none": "none",
"noResultsFromQuery": "the query returned no results",
"numberOfResults": "{{numberOfResults}} results",
"noFilters": "no filters configured",
"note": "note",
"ok": "ok",
@@ -360,6 +364,9 @@
"input_name": "name",
"input_streamUrl": "stream url"
},
"editRadioStation": {
"success": "radio station updated successfully"
},
"deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm",
"success": "$t(entity.playlist, {\"count\": 1}) deleted successfully",
@@ -367,7 +374,6 @@
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
"title": "edit $t(entity.playlist, {\"count\": 1})"
},
@@ -898,6 +904,8 @@
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lastfm_description": "show links to Last.fm on artist/album pages",
"lastfm": "show last.fm links",
"listenbrainz_description": "show links to ListenBrainz on artist/album pages",
"listenbrainz": "show ListenBrainz links",
"lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art",
"lastfmApiKey": "{{lastfm}} API key",
"lyricFetch_description": "fetch lyrics from various internet sources",
@@ -925,6 +933,8 @@
"mpvExtraParameters_help": "one per line",
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
"musicbrainz": "show MusicBrainz links",
"qobuz_description": "show links to Qobuz on artist/album pages",
"qobuz": "show Qobuz links",
"spotify_description": "show links to Spotify on artist/album pages",
"spotify": "show Spotify links",
"nativeSpotify_description": "open in the Spotify app instead of your browser",
@@ -1036,6 +1046,10 @@
"sidePlayQueueStyle_description": "sets the style of the side play queue",
"sidePlayQueueStyle_optionAttached": "attached",
"sidePlayQueueStyle_optionDetached": "detached",
"sidePlayQueueLayout": "side play queue layout",
"sidePlayQueueLayout_description": "sets the layout of the attached side play queue",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertical",
"mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen",
"mediaSession": "enable media session",
"sidePlayQueueStyle": "side play queue style",
@@ -1071,6 +1085,8 @@
"volumeWheelStep": "volume wheel step",
"volumeWidth_description": "the width of the volume slider",
"volumeWidth": "volume slider width",
"waveformLoadingDelay": "waveform loading delay",
"waveformLoadingDelay_description": "delay in seconds before loading waveform. increase this value if you are experiencing stutters when using the web player.",
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
"webAudio": "use web audio",
"windowBarStyle_description": "select the style of the window bar",
@@ -1198,6 +1214,12 @@
"mainText": "drop a file here"
},
"visualizer": {
"systemAudioConsentAllow": "Allow",
"systemAudioConsentBody": "The visualizer requires access to the system audio to work",
"systemAudioConsentDecline": "Deny",
"systemAudioConsentTitle": "Allow access to system audio?",
"systemAudioCaptureFailed": "Could not start capture: {{message}}",
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
"visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)",
+39 -12
View File
@@ -216,7 +216,7 @@
"passwordStore": "contraseñas/almacenamiento secreto",
"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",
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en las páginas de artistas/álbumes",
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
"clearCacheSuccess": "Caché limpiada correctamente",
"externalLinks": "Mostrar enlaces externos",
@@ -243,13 +243,13 @@
"transcodeFormat": "formato a transcodificar",
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
"albumBackground": "imagen de fondo del álbum",
"albumBackground_description": "Añade una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
"albumBackground_description": "Añade una imagen de fondo a las páginas de álbumes que contienen la carátula del álbum",
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
"albumBackgroundBlur_description": "Ajusta la cantidad de desenfoque aplicado a la imagen de fondo del álbum",
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
"playerbarOpenDrawer_description": "Permite hacer clic en la barra del reproductor para abrir el reproductor a pantalla completa",
"artistConfiguration": "Configuración de la página del artista del álbum",
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página del artista del álbum",
"artistConfiguration": "Configuración de la página de artistas del álbum",
"artistConfiguration_description": "Configura qué elementos se muestran y en qué orden en la página de artistas del álbum",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "Mostrar en el área de notificación",
"trayEnabled_description": "muestra/oculta el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
@@ -293,7 +293,7 @@
"releaseChannel_optionBeta": "Beta",
"releaseChannel": "Canal de lanzamiento",
"releaseChannel_description": "Elige entre lanzamientos estables, beta, o alpha (nightly) para las actualizaciones automáticas",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artista que contienen el arte del artista",
"artistBackground_description": "Añade una imagen de fondo para las páginas de artistas que contienen el arte de los artistas",
"mediaSession": "Activar sesión de medios",
"mediaSession_description": "Activa la integración de la sesión de medios, mostrando los controles de medios y los metadatos en la superposición del volumen del sistema y en la pantalla de bloqueo",
"exportImportSettings_control_description": "Exporta e importa la configuración a través de JSON",
@@ -370,7 +370,7 @@
"combinedLyricsAndVisualizer_description": "Combina letras y visualizador en el mismo panel",
"combinedLyricsAndVisualizer": "Combinar letras y visualizador en la barra lateral del reproductor",
"artistReleaseTypeConfiguration": "Configuración de tipo de lanzamiento de artista",
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página del artista del álbum",
"artistReleaseTypeConfiguration_description": "Configura qué tipos de lanzamiento son mostrados, y en qué orden, en la página de artistas del álbum",
"mpvExtraParameters": "Parámetros adicionales de MPV",
"mpvExtraParameters_description": "Argumentos adicionales a pasar a MPV",
"hotkey_listPlayDefault": "Reproducir lista",
@@ -411,7 +411,21 @@
"autosave": "Guardar automáticamente la cola de reproducción",
"autosaveCount": "Frecuencia de guardado automática de la cola de reproducción",
"autosave_description": "Permite guardar automáticamente la cola de reproducción en tu servidor. Esto solo es posible cuando se usa Navidrome/Subsonic, y no puedes tener una cola de reproducción mezclada.",
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian"
"autosaveCount_description": "Cuántas pistas cambian antes de que la cola sea guardada. 1 (mínimo) quiere decir que todas las canciones cambian",
"spotify_description": "Muestra enlaces a Spotify en las páginas de artistas/álbumes",
"spotify": "Mostrar enlaces de Spotify",
"nativeSpotify_description": "Abre en la aplicación de Spotify en lugar de tu navegador",
"nativeSpotify": "Usar la aplicación de Spotify",
"listenbrainz": "Mostrar enlaces a ListenBrainz",
"listenbrainz_description": "Muestra enlaces a ListenBrainz en las páginas de artistas/álbumes",
"qobuz_description": "Muestra enlaces a Qobuz en las páginas de artistas/álbumes",
"qobuz": "Mostrar enlaces a Qobuz",
"sidePlayQueueLayout_optionHorizontal": "Horizontal",
"sidePlayQueueLayout_optionVertical": "Vertical",
"sidePlayQueueLayout": "Diseño de la cola de reproducción lateral",
"sidePlayQueueLayout_description": "Establece el diseño de la cola de reproducción lateral adjunta",
"waveformLoadingDelay": "Retraso de carga de la forma de onda",
"waveformLoadingDelay_description": "Retraso en segundos antes de cargar la forma de onda. Incrementa este valor si estás experimentando tartamudeos al usar el reproductor web."
},
"action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -433,7 +447,10 @@
"removeFromFavorites": "eliminar de $t(entity.favorite, {\"count\": 2})",
"openIn": {
"lastfm": "Abrir en Last.fm",
"musicbrainz": "Abrir en MusicBrainz"
"musicbrainz": "Abrir en MusicBrainz",
"spotify": "Abrir en Spotify",
"listenbrainz": "Abrir en ListenBrainz",
"qobuz": "Abrir en Qobuz"
},
"moveToNext": "pasar al siguiente",
"downloadStarted": "Iniciada descarga de {{count}} elementos",
@@ -579,7 +596,9 @@
"filter_single": "simple",
"filter_multiple": "multi",
"rename": "Renombrar",
"newVersionAvailable": "Una nueva versión está disponible"
"newVersionAvailable": "Una nueva versión está disponible",
"numberOfResults": "{{numberOfResults}} resultados",
"grouping": "Agrupar"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -927,8 +946,7 @@
"editPlaylist": {
"title": "editar $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) actualizada correctamente",
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada",
"editNote": "No se recomiendan las ediciones manuales para grandes listas de reproducción. ¿Seguro que aceptas el riesgo de pérdida de información incurrido por sobrescribir la lista de reproducción existente?"
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada"
},
"queryEditor": {
"input_optionMatchAll": "coincidir todos",
@@ -983,6 +1001,9 @@
"export": "Exportar letras",
"input_synced": "Exportar letras sincronizadas",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Estación de radio actualizada con éxito"
}
},
"table": {
@@ -1360,6 +1381,12 @@
"lowResolution": "Baja resolución",
"splitGradient": "Dividir degradado",
"noteLabels": "Etiquetas de notas",
"lumiBars": "Barras luminiscentes"
"lumiBars": "Barras luminiscentes",
"systemAudioConsentAllow": "Permitir",
"systemAudioConsentDecline": "Denegar",
"systemAudioConsentTitle": "¿Permitir acceso al sistema de audio?",
"systemAudioConsentBody": "El visualizador requiere acceso al sistema de audio para funcionar",
"systemAudioCaptureFailed": "No se pudo iniciar la captura: {{message}}",
"systemAudioNoAudioTrack": "Ninguna pista de audio devuelta. Asegúrate de que la captura de audio está activada cuando se solicite."
}
}
+78 -16
View File
@@ -15,7 +15,10 @@
"viewPlaylists": "ikusi $t(entity.playlist, {\"count\": 2})",
"openIn": {
"lastfm": "Ireki Last.fm-n",
"musicbrainz": "Ireki MusicBrainz-en"
"musicbrainz": "Ireki MusicBrainz-en",
"listenbrainz": "Ireki ListenBrainz-en",
"qobuz": "Ireki Qobuz-en",
"spotify": "Ireki Spotify-n"
},
"clearQueue": "garbitu ilara",
"createPlaylist": "sortu $t(entity.playlist, {\"count\": 1})",
@@ -33,7 +36,8 @@
"shuffleAll": "nahastu dena",
"shuffleSelected": "nahastu aukeratutak",
"moveItems": "elementuak mugitu",
"openApplicationDirectory": "ireki aplikazioaren direktorioa"
"openApplicationDirectory": "ireki aplikazioaren direktorioa",
"goToCurrent": "joan uneko elementura"
},
"common": {
"add": "gehitu",
@@ -67,8 +71,8 @@
"filter_other": "iragazkiak",
"filters": "iragazkiak",
"forceRestartRequired": "berreabiarazi aldaketak aplikatzeko... itxi notifikazioa berreabiarazteko",
"setting_one": "ezarpenak",
"setting_other": "",
"setting_one": "ezarpena",
"setting_other": "ezarpenak",
"share": "partekatu",
"action_one": "ekintza",
"action_other": "ekintzak",
@@ -150,7 +154,10 @@
"recordLabel": "diskoetxea",
"example": "adibidea",
"tableColumns": "taulako zutabeak",
"doNotShowAgain": "ez erakutsi hau berriro"
"doNotShowAgain": "ez erakutsi hau berriro",
"numberOfResults": "{{numberOfResults}} emaitza",
"rename": "berrizendatu",
"newVersionAvailable": "bertsio berri bat eskuragarri dago"
},
"player": {
"repeat": "errepikatu",
@@ -351,7 +358,11 @@
"noNetwork": "zerbitzaria ez dago erabilgarri",
"noNetworkDescription": "ezin izan da zerbitzari honetara konektatu",
"saveQueueFailed": "huts egin du ilara gordetzean",
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen"
"multipleServerSaveQueueError": "erreprodukzio-ilarak zerbitzarikoak ez diren abesti bat edo gehiago ditu. hau ez da onartzen",
"invalidJson": "JSON baliogabea",
"playbackPausedDueToError": "erreprodukzioa eten egin da errore baten ondorioz",
"serverLockSingleServer": "zerbitzaria blokeatuta dagoenean, zerbitzari bakarra onartzen da",
"settingsSyncError": "desadostasunak aurkitu dira errendatzailearen ezarpenen eta prozesu nagusiaren artean. berrabiarazi aplikazioa aldaketak aplikatzeko"
},
"filter": {
"disc": "diskoa",
@@ -371,7 +382,7 @@
"biography": "biografia",
"bitrate": "bit-emaria",
"bpm": "bpm-ak",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"comment": "iruzkina",
"favorited": "gogoko gisa markatua",
"genre": "$t(entity.genre, {\"count\": 1})",
@@ -396,7 +407,9 @@
"releaseYear": "argitalpen urtea",
"toYear": "urtera arte",
"fromYear": "urtetik aurrera",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "eta",
"matchOr": "edo"
},
"setting": {
"hotkey_playbackPause": "pausatu",
@@ -561,7 +574,7 @@
"hotkey_browserForward": "nabigatzailean aurreraka",
"imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa",
"lyricFetchProvider": "letrak eskuratzeko hornitzaileak",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak",
"minimizeToTray": "minimizatu erretilura",
"minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura",
"minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)",
@@ -675,7 +688,33 @@
"remotePort_description": "urruneko kontrol zerbitzariaren portua ezartzen du",
"remotePort": "urruneko kontrol zerbitzariaren ataka",
"remoteUsername_description": "urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da",
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena"
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena",
"logLevel_optionWarn": "abisua",
"qobuz_description": "erakutsi Qobuz-erako estekak artista/album orrialdeetan",
"qobuz": "erakutsi Qobuz-erako estekak",
"spotify_description": "erakutsi Spotify-rako estekak artista/album orrialdeetan",
"spotify": "erakutsi Spotify-rako estekak",
"nativeSpotify_description": "ireki Spotify aplikazioan, arakatzailearen ordez",
"nativeSpotify": "erabili Spotify aplikazioa",
"playerbarSlider_description": "uhin-forma ez da gomendagarria interneteko konexio motela edo neurtua baduzu",
"playerbarSliderType_optionWaveform": "uhin-forma",
"playerbarWaveformAlign": "uhin-formaren lerrokatzea",
"playerbarWaveformAlign_optionTop": "nagusia",
"playerbarWaveformBarWidth": "uhin-formako barraren zabalera",
"playerbarWaveformGap": "uhin-formaren tartea",
"playerbarWaveformRadius": "uhin-formaren erradioa",
"showLyricsInSidebar_description": "letrak erakusten dituen panel bat gehituko da erantsitako erreprodukzio-ilaran",
"showLyricsInSidebar": "erakutsi letra erreproduzitzailearen alboko barran",
"blurExplicitImages": "irudi esplizituak lausotu",
"blurExplicitImages_description": "esplizitu gisa etiketatutako albumaren eta abestiaren azalak lausotuta agertuko dira",
"enableGridMultiSelect": "gaitu sareta anitzeko hautaketa",
"enableGridMultiSelect_description": "gaituta dagoenean, sareta-ikuspegietan hainbat elementu hautatzea ahalbidetzen du. desgaituta dagoenean, sareta-elementuen irudietan klik egitean elementuaren orrialdera nabigatuko da",
"showVisualizerInSidebar_description": "bistaratzailea erakusten duen panel bat gehituko da erreproduzitzailearen alboko barran",
"preservePitch_description": "erreprodukzio-abiadura aldatzean tonua mantentzen du",
"preservePitch": "mantendu tonua",
"preventSleepOnPlayback": "erreprodukzioan loa saihestu",
"replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz",
"replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera"
},
"form": {
"addServer": {
@@ -731,15 +770,16 @@
"editPlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala eguneratu da",
"title": "$t(entity.playlist, {\"count\": 1}) editatu",
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau",
"editNote": "ez da gomendatzen eskuzko edizioak egitea erreprodukzio-zerrenda handietarako. ziur zaude onartzen duzula lehendik dagoen erreprodukzio-zerrenda gainidazteagatik datuak galtzeko arriskua?"
"publicJellyfinNote": "Arrazoiren batengatik, Jellyfin ez du erakusten erreprodukzio-zerrendak publikoak diren edo ez. Hau publiko izaten jarraitzea nahi baduzu, hautatu sarrera hau"
},
"queryEditor": {
"title": "kontsulta editorea",
"input_optionMatchAll": "guztiak bat etorri",
"input_optionMatchAny": "edozeinekin bat etorri",
"resetToDefault": "lehenetsitako egoerara berrezarri",
"clearFilters": "garbitu iragazkiak"
"clearFilters": "garbitu iragazkiak",
"addRuleGroup": "gehitu arau-taldea",
"removeRuleGroup": "kendu arau-taldea"
},
"updateServer": {
"success": "zerbitzaria behar bezala eguneratu da",
@@ -751,7 +791,8 @@
"disabled": "modu pribatua desgaituta, erreprodukzio egoera ikusgai dago orain gaitutako kanpoko integrazioentzat"
},
"largeFetchConfirmation": {
"title": "gehitu elementuak ilaran"
"title": "gehitu elementuak ilaran",
"description": "Ekintza honek uneko iragazki-ikuspegian dauden elementu guztiak gehituko ditu"
},
"createRadioStation": {
"input_homepageUrl": "hasierako orriaren URLa",
@@ -928,7 +969,8 @@
"nowPlaying": "orain erreproduzitzen",
"shared": "partekatutako $t(entity.playlist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "bildumak"
},
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
@@ -1097,6 +1139,26 @@
"saveAsPreset": "Aurrezarpen gisa gorde",
"applyPreset": "Aurrezarpena Aplikatu",
"selectPreset": "Aukeratu Aurrezarpena",
"presets": "Aurrezarpenak"
"presets": "Aurrezarpenak",
"visualizerType": "Bistaratzaile Mota",
"cycleTime": "Zikloaren denbora (segundoak)",
"includeAllPresets": "Aurrezarpen guztiak sartu",
"ignoredPresets": "Aurrezarpen baztertuak",
"selectedPresets": "Hautatutako aurrezarpenak",
"mode1To8": "1 - 8 modua",
"mode10": "10 modua",
"gradientLeft": "Gradientearen ezkerra",
"gradientRight": "Gradientearen eskuina",
"peakBehavior": "Gailurraren Portaera",
"peakLine": "Gailurraren lerroa",
"miscellaneousSettings": "Hainbat ezarpen",
"alphaBars": "Alfa barrak",
"ansiBands": "ANSI bandak",
"ledBars": "LED barrak",
"trueLeds": "True LED-ak",
"roundBars": "Barra biribilduak",
"lowResolution": "Erresoluzio baxua",
"showFPS": "Erakutsi FPS",
"showScaleX": "Erakutsi X eskala"
}
}
+1 -2
View File
@@ -332,8 +332,7 @@
"editPlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) päivitetty onnistuneesti",
"title": "muokkaa $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna",
"editNote": "manuaalisia muokkauksia ei suositella suurille soittolistoille. haluatko varmasti hyväksyä riskin, että nykyinen soittolista ylikirjoitetaan ja tietoja voi hävitä?"
"publicJellyfinNote": "Jellyfin ei jostain syystä kerro onko soittolista julkinen vai ei. Jos haluat sen pysyvän julkisena, pidä seuraava valinta valittuna"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
+41 -14
View File
@@ -70,7 +70,10 @@
"removeFromFavorites": "retirer des $t(entity.favorite, {\"count\": 2})",
"openIn": {
"lastfm": "Ouvrir dans Last.fm",
"musicbrainz": "Ouvrir dans MusicBrainz"
"musicbrainz": "Ouvrir dans MusicBrainz",
"listenbrainz": "Ouvrir dans ListenBrainz",
"qobuz": "Ouvrir dans Qobuz",
"spotify": "Ouvrir dans Spotify"
},
"moveToNext": "passer au suivant",
"downloadStarted": "téléchargement de {{count}} éléments en cours",
@@ -180,6 +183,7 @@
"albumPeak": "crête de l'album",
"close": "fermer",
"mbid": "Identifiant MusicBrainz",
"grouping": "regroupement",
"preview": "aperçu",
"share": "partager",
"reload": "recharger",
@@ -217,7 +221,8 @@
"filter_single": "unique",
"filter_multiple": "multiple",
"rename": "renommer",
"newVersionAvailable": "une nouvelle version est disponible"
"newVersionAvailable": "une nouvelle version est disponible",
"numberOfResults": "{{numberOfResults}} résultats"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -238,7 +243,7 @@
"mpvRequired": "MPV requis",
"audioDeviceFetchError": "une erreur sest produite lors de la tentative dobtenir les périphériques audio",
"invalidServer": "serveur invalide",
"loginRateError": "trop de tentative de connexion, merci de réessayer dans quelques secondes",
"loginRateError": "trop de tentatives de connexion, merci de réessayer dans quelques secondes",
"openError": "impossible d'ouvrir le fichier",
"networkError": "une erreur de réseau est survenue",
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
@@ -874,7 +879,21 @@
"hotkey_listPlayNext": "lire ensuite",
"hotkey_listPlayNow": "lire maintenant",
"playerItemConfiguration_description": "configurer les éléments affichés et leur ordre dans le lecteur plein écran",
"playerItemConfiguration": "configuration des éléments du lecteur"
"playerItemConfiguration": "configuration des éléments du lecteur",
"listenbrainz_description": "afficher les liens vers ListenBrainz sur les pages d'artiste/album",
"listenbrainz": "afficher les liens ListenBrainz",
"qobuz_description": "afficher les liens vers Qobuz sur les pages d'artiste/album",
"qobuz": "afficher les liens Qobuz",
"spotify_description": "afficher les liens vers Spotify sur les pages d'artiste/album",
"spotify": "afficher les liens Spotify",
"nativeSpotify_description": "ouvrir dans l'application Spotify plutôt que le navigateur",
"nativeSpotify": "utiliser l'application Spotify",
"sidePlayQueueLayout": "disposition de la file d'attente",
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertical",
"waveformLoadingDelay": "délai de chargement de la forme d'onde",
"waveformLoadingDelay_description": "délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
},
"form": {
"deletePlaylist": {
@@ -932,8 +951,7 @@
"editPlaylist": {
"title": "modifier $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Jellyfin n'indique pas si une liste de lecture est publique ou non. Si vous souhaitez que cette liste de lecture reste publique, veuillez sélectionner l'entrée suivante",
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès",
"editNote": "les modifications manuelles ne sont pas recommandées pour les listes de lecture volumineuses. êtes-vous sûre d'accepter le risque d'une perte de données en écrasant la liste de lecture existante?"
"success": "$t(entity.playlist, {\"count\": 1}) mis à jour avec succès"
},
"lyricSearch": {
"title": "recherche de paroles",
@@ -984,6 +1002,9 @@
"export": "exporter les paroles",
"input_synced": "exporter les paroles synchronisées",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "station de radio a été mise à jour avec succès"
}
},
"entity": {
@@ -1074,7 +1095,7 @@
"pagination_itemsPerPage": "entrées par page",
"pagination_infinite": "infini",
"pagination_paginate": "paginé",
"alternateRowColors": "alterner les couleurs des lignes",
"alternateRowColors": "alterner la couleur des lignes",
"horizontalBorders": "bordures de ligne",
"rowHoverHighlight": "surligner les lignes au survol",
"verticalBorders": "bordure de colonne",
@@ -1216,12 +1237,12 @@
},
"visualizer": {
"visualizerType": "type de visualisateur",
"cyclePresets": "cycle les préréglages",
"cycleTime": "temps de cycle (secondes)",
"cyclePresets": "cycler les préréglages",
"cycleTime": "durée d'un cycle (secondes)",
"includeAllPresets": "inclure tous les préréglages",
"ignoredPresets": "préréglages ignorés",
"selectedPresets": "préréglages sélectionné",
"randomizeNextPreset": "randomiser le préréglage suivant",
"selectedPresets": "préréglages sélectionnés",
"randomizeNextPreset": "préréglage suivant aléatoire",
"blendTime": "temps de mélange",
"presets": "préréglages",
"selectPreset": "sélectionner un préréglage",
@@ -1231,7 +1252,7 @@
"copyConfiguration": "copier la configuration",
"pasteConfiguration": "coller la configuration",
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
"pasteFromClipboard": "coller depuis le presse-papier",
"pasteFromClipboard": "coller depuis le presse-papiers",
"applyConfiguration": "appliquer la configuration",
"configCopied": "configuration copiée dans le presse-papiers",
"configCopyFailed": "échec de la copie de la configuration",
@@ -1256,7 +1277,7 @@
"gradientNamePlaceholder": "nom du dégradé",
"vertical": "verticale",
"horizontal": "horizontale",
"colorStops": "couleur d'arrêts",
"colorStops": "Points de Couleur",
"addColor": "ajouter un couleur",
"position": "position",
"level": "niveau",
@@ -1361,6 +1382,12 @@
},
"lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars",
"splitGradient": "Split Gradient"
"splitGradient": "Split Gradient",
"systemAudioNoAudioTrack": "Aucune piste audio n'a été renvoyée. Assurez-vous que la capture audio est activée lorsque vous y êtes invité.",
"systemAudioConsentAllow": "Permettre",
"systemAudioConsentBody": "Le visualiseur nécessite un accès au système audio pour fonctionner",
"systemAudioConsentDecline": "Refuser",
"systemAudioConsentTitle": "Permettre l'accès au système audio?",
"systemAudioCaptureFailed": "Impossible de démarrer la capture : {{message}}"
}
}
+1 -2
View File
@@ -313,8 +313,7 @@
"editPlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) sikeresen módosítva",
"publicJellyfinNote": "A Jellyfin valamiért nem teszi közzé, hogy egy lejátszási lista publikus-e vagy sem. Amennyiben azt szeretnéd, hogy publikus maradjon, válaszd ki az alábbi beviteli mezőt",
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})",
"editNote": "A kézi szerkesztés nem ajánlott nagy lejátszási listák esetén. Biztosan vállalod a meglévő lejátszási lista felülírásával járó adatvesztés kockázatát?"
"title": "szerkesztés $t(entity.playlist, {\"count\": 1})"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
+1 -2
View File
@@ -304,8 +304,7 @@
"editPlaylist": {
"publicJellyfinNote": "Jellyfin entah bagaimana tidak menampilkan apakah playlist ini publik atau tidak. Jika Anda ingin playlist ini tetap publik, harap pilih entri berikut",
"success": "$t(entity.playlist, {\"count\": 1}) berhasil diperbarui",
"title": "ubah $t(entity.playlist, {\"count\": 1})",
"editNote": "pengeditan manual tidak disarankan untuk playlist besar. apakah Anda yakin menerima risiko kehilangan data yang timbul akibat menimpa playlist yang ada?"
"title": "ubah $t(entity.playlist, {\"count\": 1})"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
+1
View File
@@ -128,6 +128,7 @@
"close": "chiudi",
"codec": "codec",
"mbid": "MusicBrainz ID",
"grouping": "raggruppamento",
"preview": "anteprima",
"reload": "aggiorna",
"share": "condividi",
+90 -63
View File
@@ -75,7 +75,7 @@
"mpvExecutablePath_description": "MPV を実行するファイルパスを設定します。空のままにすると、デフォルトのパスが使用されます",
"replayGainClipping_description": "自動的にゲインを下げて {{ReplayGain}} によるクリッピングを防ぎます",
"replayGainPreamp": "{{ReplayGain}} プリアンプ (dB)",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入り",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) をお気に入りに登録",
"sampleRate": "サンプルレート",
"sidePlayQueueStyle_optionAttached": "結合",
"sidebarConfiguration": "サイドバー設定",
@@ -90,19 +90,19 @@
"themeLight": "テーマ (ライト)",
"fontType_optionBuiltIn": "組み込みフォント",
"hotkey_playbackPlayPause": "再生 / 一時停止",
"hotkey_rate1": "1つ星で評価",
"hotkey_rate1": "1 つ星で評価",
"hotkey_skipForward": "次へスキップ",
"disableLibraryUpdateOnStartup": "起動時の新バージョンチェックを無効にします",
"discordApplicationId_description": "{{discord}} に Rich Presence ステータスを表示するためのアプリケーション ID (デフォルトは {{defaultId}} です)",
"sidePlayQueueStyle": "サイド再生キュースタイル",
"sidePlayQueueStyle": "サイド再生キューの形式",
"gaplessAudio": "ギャップレス再生",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"zoom": "ズーム率",
"minimizeToTray_description": "最小化ボタンが押された際、システムトレイに格納します",
"hotkey_playbackPlay": "再生",
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) お気に入り登録/解除",
"hotkey_togglePreviousSongFavorite": "$t(common.previousSong) お気に入りを切り替え",
"hotkey_volumeDown": "音量を下げる",
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入り解除",
"hotkey_unfavoritePreviousSong": "$t(common.previousSong) をお気に入りから解除",
"audioPlayer_description": "再生に使用するオーディオプレーヤーを選択します",
"globalMediaHotkeys": "グローバルメディアホットキー",
"hotkey_globalSearch": "グローバル検索",
@@ -110,7 +110,7 @@
"remoteUsername_description": "リモートコントロール サーバーのユーザ名を設定します。 ユーザー名とパスワードの両方が空の場合、認証は無効になります",
"exitToTray_description": "アプリケーション終了ボタンが押された際、システムトレイに格納します",
"followLyric_description": "現在の再生位置に歌詞をスクロールします",
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入り",
"hotkey_favoritePreviousSong": "$t(common.previousSong) をお気に入りに登録",
"replayGainMode_optionAlbum": "$t(entity.album, {\"count\": 1})",
"lyricOffset": "歌詞のオフセット (ミリ秒)",
"discordUpdateInterval_description": "更新間隔 (秒単位、最小 15 秒)",
@@ -121,7 +121,7 @@
"lyricFetchProvider": "歌詞取得先",
"language_description": "アプリケーションの言語を設定します ($t(common.restartRequired))",
"playbackStyle_optionCrossFade": "クロスフェード",
"hotkey_rate3": "3つ星で評価",
"hotkey_rate3": "3 つ星で評価",
"font": "フォント",
"replayGainMode_optionTrack": "$t(entity.track, {\"count\": 1})",
"themeLight_description": "アプリケーションに使用するライトテーマを設定します",
@@ -130,7 +130,7 @@
"hotkey_toggleQueue": "キューの切り替え",
"zoom_description": "アプリケーションのズーム率を設定します",
"remotePassword_description": "リモートコントロール サーバーのパスワードを設定します。 ログイン情報はデフォルトでセキュアな通信がされないため、個人情報と関係ないランダムなパスワードを利用してください",
"hotkey_rate5": "5つ星で評価",
"hotkey_rate5": "5 つ星で評価",
"hotkey_playbackPrevious": "前のトラック",
"showSkipButtons_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
"crossfadeDuration_description": "クロスフェード効果の時間を設定します",
@@ -141,11 +141,11 @@
"discordRichPresence_description": "{{discord}} Rich Presence で再生ステータスを有効にします。画像キー: {{icon}}, {{playing}}, {{paused}}",
"mpvExecutablePath": "MPV 実行ファイルパス",
"audioDevice": "オーディオデバイス",
"hotkey_rate2": "2つ星で評価",
"hotkey_rate2": "2 つ星で評価",
"playButtonBehavior_description": "キューに曲を追加するときの再生ボタンのデフォルトの動作を設定します",
"minimumScrobblePercentage_description": "Scrobble されるために必要な最短の再生時間 (%)",
"exitToTray": "終了時にシステムトレイに格納",
"hotkey_rate4": "4つ星で評価",
"hotkey_rate4": "4 つ星で評価",
"enableRemote": "リモートコントロール サーバーを有効化",
"showSkipButton_description": "プレーヤーバーのスキップボタンを表示または非表示にします",
"savePlayQueue": "再生キューを保存",
@@ -156,7 +156,7 @@
"volumeWheelStep": "音量ホイールステップ",
"sidebarPlaylistList_description": "サイドバーのプレイリストを表示または非表示にします",
"accentColor": "アクセントカラー",
"sidePlayQueueStyle_description": "サイド再生キューのスタイルを設定します",
"sidePlayQueueStyle_description": "サイド再生キューの形式を設定します",
"accentColor_description": "アプリケーションが利用するアクセントカラーを設定します",
"replayGainMode": "{{ReplayGain}} モード",
"playbackStyle_optionNormal": "通常",
@@ -182,12 +182,12 @@
"sidePlayQueueStyle_optionDetached": "分離",
"audioPlayer": "オーディオプレーヤー",
"hotkey_zoomOut": "縮小",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入り解除",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) をお気に入りから解除",
"hotkey_rate0": "評価をクリア",
"discordApplicationId": "{{discord}} アプリケーション ID",
"applicationHotkeys_description": "アプリケーションのホットキーを設定します。チェックボックスを切り替えて、グローバルホットキーとして設定します (デスクトップのみ)",
"hotkey_volumeMute": "音量をミュート",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) お気に入り登録/解除",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) お気に入りを切り替え",
"remoteUsername": "リモートコントロールサーバーのユーザー名",
"hotkey_browserBack": "ブラウザ 戻る",
"showSkipButton": "スキップボタンを表示",
@@ -221,8 +221,8 @@
"volumeWidth": "音量スライダーの幅",
"webAudio_description": "Web Audio を使用します。これにより、リプレイゲインなどの高度な機能が有効になります。問題が発生した場合は無効にしてください",
"mpvExtraParameters_help": "1 行に 1 つずつ",
"musicbrainz_description": "MusicBrainz ID が存在するアーティスト/アルバムページに MusicBrainz へのリンクを表示します",
"musicbrainz": "MusicBrainz リンクを表示する",
"musicbrainz_description": "MusicBrainz ID が存在するアーティストアルバムページに MusicBrainz へのリンクを表示します",
"musicbrainz": "MusicBrainz リンクを表示",
"neteaseTranslation_description": "有効にすると、利用可能な場合は NetEase から翻訳された歌詞を取得して表示します",
"neteaseTranslation": "NetEase 翻訳歌詞を有効にする",
"passwordStore_description": "使用するパスワード / シークレットストア。パスワードの保存に問題がある場合はこれを変更してください",
@@ -238,8 +238,8 @@
"imageAspectRatio": "ネイティブのカバーアートの縦横比を使用する",
"language": "言語",
"imageAspectRatio_description": "有効にすると、カバーアートはネイティブの縦横比で表示されます。縦横比が 1:1 でない場合、残りのスペースは空白になります",
"lastfm_description": "アーティスト/アルバムページに Last.fm へのリンクを表示します",
"lastfm": "Last.fm リンクを表示する",
"lastfm_description": "アーティストアルバムページに Last.fm へのリンクを表示します",
"lastfm": "Last.fm リンクを表示",
"lastfmApiKey": "{{lastfm}} API キー",
"homeConfiguration_description": "ホーム画面に表示する項目と表示順序を設定します",
"homeConfiguration": "ホーム画面の設定",
@@ -287,7 +287,7 @@
"exportImportSettings_control_title": "設定をインポート/エクスポート",
"exportImportSettings_control_description": "JSON 経由で設定をエクスポートおよびインポートする",
"exportImportSettings_destructiveWarning": "設定のインポートは破壊的です。下の「インポート」をクリックする前に、上記の内容を必ずご確認ください!",
"hotkey_navigateHome": "ホーム移動",
"hotkey_navigateHome": "ホーム画面へ移動",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerbarOpenDrawer": "プレーヤーバーの全画面表示切り替え",
"transcode": "トランスコーディングを有効にする",
@@ -329,11 +329,11 @@
"followCurrentSong": "現在の曲をフォロー",
"followCurrentSong_description": "再生キューを現在再生中の曲まで自動的にスクロールします",
"logLevel": "ログレベル",
"logLevel_description": "表示するログの最小レベルを設定します。debug はすべてのログを表示し、error はエラーのみを表示します",
"logLevel_optionDebug": "debug",
"logLevel_optionError": "error",
"logLevel_optionInfo": "info",
"logLevel_optionWarn": "警告する",
"logLevel_description": "表示するログの最小レベルを設定します。Debug はすべてのログを表示し、Error はエラーのみを表示します",
"logLevel_optionDebug": "Debug",
"logLevel_optionError": "Error",
"logLevel_optionInfo": "Info",
"logLevel_optionWarn": "Warn",
"playerFilters": "キューから曲をフィルタリング",
"playerFilters_description": "以下の基準に基づいて曲をキューに追加しないようにします",
"artistRadioCount": "アーティスト / トラックのラジオカウント",
@@ -407,11 +407,25 @@
"playerbarWaveformBarWidth": "波形バーの幅",
"playerbarWaveformGap": "波形ギャップ",
"playerbarWaveformRadius": "波形半径",
"hotkey_listNavigateToPage": "リストのアイテムページへ移動",
"hotkey_listPlayDefault": "リスト再生",
"hotkey_listPlayLast": "リストの最後再生",
"hotkey_listPlayNext": "リスト 再生 次へ",
"hotkey_listPlayNow": "今すぐリストを再生"
"hotkey_listNavigateToPage": "項目の詳細ページへ移動",
"hotkey_listPlayDefault": "リスト再生 (デフォルト)",
"hotkey_listPlayLast": "最後再生",
"hotkey_listPlayNext": "次に再生",
"hotkey_listPlayNow": "今すぐ再生",
"spotify_description": "アーティストとアルバムページに Spotify へのリンクを表示します",
"spotify": "Spotify のリンクを表示",
"nativeSpotify_description": "ブラウザーの代わりに Spotify アプリで開きます",
"nativeSpotify": "Spotify アプリを使用",
"listenbrainz_description": "アーティストとアルバムページに ListenBrainz へのリンクを表示します",
"listenbrainz": "ListenBrainz のリンクを表示",
"qobuz_description": "アーティストとアルバムページに Qobuz へのリンクを表示します",
"qobuz": "Qobuz のリンクを表示",
"sidePlayQueueLayout": "サイド再生キューのレイアウト",
"sidePlayQueueLayout_description": "結合されたサイド再生キューのレイアウトを設定します",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形読み込みの遅延",
"waveformLoadingDelay_description": "波形を読み込むまでの遅延時間(秒単位)を設定します。Web プレーヤー使用時にカクつきが発生する場合は、この値を増やしてください。"
},
"action": {
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
@@ -433,7 +447,10 @@
"removeFromFavorites": "$t(entity.favorite, {\"count\": 2}) から削除",
"openIn": {
"lastfm": "Last.fm で開く",
"musicbrainz": "MusicBrainz で開く"
"musicbrainz": "MusicBrainz で開く",
"spotify": "Spotify で開く",
"listenbrainz": "ListenBrainz で開く",
"qobuz": "Qobuz で開く"
},
"moveToNext": "次",
"downloadStarted": "{{count}} 曲のダウンロードを開始しました",
@@ -537,7 +554,7 @@
"bitDepth": "ビット深度",
"close": "閉じる",
"codec": "コーデック",
"mbid": "MusicBrainz識別子",
"mbid": "MusicBrainz ID",
"sampleRate": "サンプルレート",
"preview": "プレビュー",
"private": "プライベート",
@@ -571,7 +588,9 @@
"filter_single": "シングル",
"filter_multiple": "複数枚組",
"rename": "名前を変更",
"newVersionAvailable": "新しいバージョンが利用可能です"
"newVersionAvailable": "新しいバージョンが利用可能です",
"numberOfResults": "{{numberOfResults}} 件の結果",
"grouping": "グループ化"
},
"table": {
"config": {
@@ -584,7 +603,7 @@
"general": {
"displayType": "表示タイプ",
"gap": "$t(common.gap)",
"tableColumns": "テーブル カラム",
"tableColumns": "テーブル",
"autoFitColumns": "カラム長を自動調整",
"size": "$t(common.size)",
"itemSize": "項目のサイズ (px)",
@@ -1018,7 +1037,7 @@
},
"updateServer": {
"title": "サーバーをアップデート",
"success": "サーバーがアップデートされました"
"success": "サーバーの更新に成功しました"
},
"queryEditor": {
"input_optionMatchAll": "すべて一致",
@@ -1037,8 +1056,7 @@
"editPlaylist": {
"title": "$t(entity.playlist, {\"count\": 1}) を編集",
"publicJellyfinNote": "Jellyfin では、何らかの理由でプレイリストが公開されているかどうかが表示されません。公開されたままにしたい場合は、以下の項目を選択してください",
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました",
"editNote": "大規模なプレイリストの場合、手動編集は推奨されません。既存のプレイリストを上書きすることでデータ損失が発生するリスクを許容しますか?"
"success": "$t(entity.playlist, {\"count\": 1}) が正常に更新されました"
},
"shareItem": {
"allowDownloading": "ダウンロードを許可",
@@ -1064,7 +1082,7 @@
"title": "ラジオ局を作成",
"input_homepageUrl": "ホームページ URL",
"input_name": "名前",
"input_streamUrl": "ストリームURL"
"input_streamUrl": "ストリーム URL"
},
"lyricsExport": {
"export": "歌詞をエクスポート",
@@ -1084,6 +1102,9 @@
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
}
},
"entity": {
@@ -1125,12 +1146,12 @@
"audiobook": "オーディオブック",
"audioDrama": "オーディオドラマ",
"compilation": "コンピレーション",
"djMix": "DJミックス",
"djMix": "DJ ミックス",
"demo": "デモ",
"soundtrack": "サウンドトラック",
"fieldRecording": "フィールドレコーディング",
"interview": "インタビュー",
"live": "生で",
"live": "ライブ",
"mixtape": "ミックステープ",
"remix": "リミックス",
"spokenWord": "スポークン・ワード"
@@ -1184,20 +1205,20 @@
"updatePreset": "プリセットを更新",
"copyConfiguration": "設定をコピーする",
"pasteConfiguration": "設定を貼り付け",
"pasteConfigurationPlaceholder": "ここにJSON設定を貼り付けてください...",
"pasteConfigurationPlaceholder": "ここに JSON 設定を貼り付けてください...",
"pasteFromClipboard": "クリップボードから貼り付け",
"applyConfiguration": "設定を適用",
"configCopied": "設定をクリップボードにコピーしました",
"configCopyFailed": "設定のコピーに失敗しました",
"configPasted": "加えられた構成 首尾よく",
"configPasted": "設定が正常に適用されました",
"configPasteFailed": "設定の適用に失敗しました。形式を確認してください。",
"configPasteReadFailed": "クリップボードからの読み取りに失敗しました",
"presetName": "プリセット名",
"presetNamePlaceholder": "プリセット名を入力",
"general": "一将",
"general": "全般",
"mode": "モード",
"mode1To8": "モード18",
"mode10": "モード10",
"mode1To8": "モード 1 - 8",
"mode10": "モード 10",
"barSpace": "バースペース",
"lineWidth": "線幅",
"fillAlpha": "アルファ塗りつぶしを設定",
@@ -1216,15 +1237,15 @@
"level": "レベル",
"remove": "取り除く",
"pasteGradient": "グラデーションを貼り付け",
"pasteGradientPlaceholder": "グラデーションのJSONをここに貼り付けてください...",
"pasteGradientPlaceholder": "グラデーションの JSON をここに貼り付けてください...",
"custom": "カスタム",
"builtIn": "組み込み",
"colorMode": "カラーモード",
"gradient": "勾配",
"gradientLeft": "左グラデーション",
"gradientRight": "右方向のグラデーション",
"gradientLeft": "左へのグラデーション",
"gradientRight": "右のグラデーション",
"fft": "高速フーリエ変換",
"fftSize": "FFTサイズ",
"fftSize": "FFT サイズ",
"smoothing": "平滑化",
"frequencyRangeAndScaling": "周波数範囲とスケーリング",
"minimumFrequency": "最小周波数",
@@ -1256,30 +1277,30 @@
"mirror": "鏡",
"miscellaneousSettings": "その他の設定",
"alphaBars": "アルファバー",
"ansiBands": "ANSIバンド",
"ledBars": "LEDバー",
"trueLeds": "真のLED",
"ansiBands": "ANSI バンド",
"ledBars": "LED バー",
"trueLeds": "真の LED",
"lumiBars": "ルミ・バー",
"outlineBars": "アウトラインバー",
"roundBars": "丸棒",
"lowResolution": "低解像度",
"splitGradient": "分割グラデーション",
"showFPS": "FPSを表示",
"showScaleX": "X軸スケールを表示",
"showFPS": "FPS を表示",
"showScaleX": "X 軸スケールを表示",
"noteLabels": "注釈ラベル",
"showScaleY": "Y軸スケールを表示",
"showScaleY": "Y 軸スケールを表示",
"options": {
"mode": {
"0": "[0] 離散周波数",
"1": "[1] 1/24オクターブ / 240バンド",
"2": "[2] 1/12オクターブ / 120バンド",
"3": "[3] 1/8オクターブ / 80バンド",
"4": "[4] 1/6オクターブ / 60バンド",
"5": "[5] 1/4オクターブ / 40バンド",
"6": "[6] 1/3オクターブ / 30バンド",
"7": "[7] 半オクターブ / 20バンド",
"8": "[8] フルオクターブ / 10バンド",
"10": "[10] 折れ線グラフ面グラフ"
"1": "[1] 1/24 オクターブ / 240 バンド",
"2": "[2] 1/12 オクターブ / 120 バンド",
"3": "[3] 1/8 オクターブ / 80 バンド",
"4": "[4] 1/6 オクターブ / 60 バンド",
"5": "[5] 1/4 オクターブ / 40 バンド",
"6": "[6] 1/3 オクターブ / 30 バンド",
"7": "[7] 半オクターブ / 20 バンド",
"8": "[8] フルオクターブ / 10 バンド",
"10": "[10] 折れ線グラフ / 面グラフ"
},
"colorMode": {
"gradient": "勾配",
@@ -1314,6 +1335,12 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioConsentAllow": "許可",
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
"systemAudioConsentDecline": "拒否",
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
}
}
+28 -8
View File
@@ -2,7 +2,8 @@
"action": {
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
"musicbrainz": "Åpne i MusicBrainz",
"spotify": "Åpne i Spotify"
},
"moveToBottom": "flytt til bunnen",
"deletePlaylist": "slett $t(entity.playlist, {\"count\": 1})",
@@ -159,7 +160,8 @@
"gridRows": "rutenettrader",
"tableColumns": "tabellkolonner",
"itemsMore": "{{count}} fler",
"explicitStatus": "grovhetsstatus"
"explicitStatus": "grovhetsstatus",
"newVersionAvailable": "en ny version er tilgjengelig"
},
"entity": {
"smartPlaylist": "smart $t(entity.playlist, {\"count\": 1})",
@@ -233,7 +235,8 @@
"saveQueueFailed": "kunne ikke lagre kø",
"multipleServerSaveQueueError": "Spillekøen har en eller flere sanger som ikke finnes på gjeldene tjener. Dette er ikke støttet",
"serverLockSingleServer": "kun én tjener er tillatt når tjener er låst",
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene"
"settingsSyncError": "avvik ble funnet mellom innstillinger i avspilleren og hovedprosessen. ta en omstart av applikasjonen for å aktivere endringene",
"playbackPausedDueToError": "avspilling ble paused på grunn av en feil"
},
"filter": {
"bpm": "bpm",
@@ -319,7 +322,8 @@
"success": "la $t(entity.trackWithCount, {\"count\": {{message}} }) til $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "legg til i $t(entity.playlist, {\"count\": 1})",
"input_skipDuplicates": "hopp over duplikater",
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"searchOrCreate": "søk $t(entity.playlist, {\"count\": 2}) eller skriv for å opprette en"
},
"deletePlaylist": {
"title": "slett $t(entity.playlist, {\"count\": 1})",
@@ -328,7 +332,8 @@
},
"editPlaylist": {
"title": "rediger $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert"
"success": "$t(entity.playlist, {\"count\": 1}) er oppdatert",
"publicJellyfinNote": "Jellyfin av en grunn kan ikke oppgi om en spilleliste er offentlig eller ikke. Hvis du ønsker at denne skal beholdes offentlig, vennligst ha følgende inndata valgt"
},
"shareItem": {
"allowDownloading": "tillat nedlasting",
@@ -336,7 +341,9 @@
"createFailed": "opprettelse av delt ressurs feilet (er deling aktivert?)",
"setExpiration": "angi utløpstid",
"success": "del lenke som er kopiert til utklippstavlen (eller klikk her for å åpne)",
"expireInvalid": "utløpstid må være et fremtidig tidspunkt"
"expireInvalid": "utløpstid må være et fremtidig tidspunkt",
"copyToClipboard": "Kopier til kopitavle: Ctrl+C, Enter",
"successMustClick": "opprettet deling. trykk her for å åpne"
},
"updateServer": {
"success": "vellykket oppdatering av serveren",
@@ -367,7 +374,19 @@
},
"lyricsExport": {
"export": "eksporter sangtekster",
"input_synced": "eksporter sunkroniserte sangtekster"
"input_synced": "eksporter sunkroniserte sangtekster",
"input_offset": "$t(setting.lyricOffset)"
},
"shuffleAll": {
"title": "spill av tilfeldig",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "hvor mange sanger?",
"input_minYear": "fra år",
"input_maxYear": "til år",
"input_played": "avspillingsfilter",
"input_played_optionAll": "alle sanger",
"input_played_optionUnplayed": "bare uavspilte sanger",
"input_played_optionPlayed": "bare avspilte sanger"
}
},
"page": {
@@ -512,7 +531,8 @@
"advanced": "avansert",
"hotkeysTab": "hurtigtaster",
"playbackTab": "avspilling",
"windowTab": "vindu"
"windowTab": "vindu",
"theme": "tema"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
+40 -9
View File
@@ -19,7 +19,10 @@
"clearQueue": "verwijder lijst",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
"musicbrainz": "Open in MusicBrainz",
"listenbrainz": "Openen in ListenBrainz",
"qobuz": "Openen in Qobuz",
"spotify": "Openen in Spotify"
},
"moveToNext": "ga naar volgende",
"downloadStarted": "begonnen met downloaden van {{count}} items",
@@ -37,7 +40,8 @@
"moveDown": "verplaats omlaag",
"holdToMoveToTop": "ingedrukt houden om naar begin te verplaatsen",
"holdToMoveToBottom": "ingedrukt houden om naar einde te verplaatsen",
"openApplicationDirectory": "applicatiemap openen"
"openApplicationDirectory": "applicatiemap openen",
"goToCurrent": "ga naar huidige item"
},
"common": {
"backward": "achteruit",
@@ -130,6 +134,7 @@
"bitDepth": "bitdiepte",
"codec": "codec",
"mbid": "MusicBrainz ID",
"grouping": "groepering",
"share": "deel",
"explicit": "expliciet",
"sampleRate": "sample rate",
@@ -159,7 +164,9 @@
"retry": "opnieuw proberen",
"filter_single": "single",
"rename": "hernoemen",
"filter_multiple": "meerdere"
"filter_multiple": "meerdere",
"numberOfResults": "{{numberOfResults}} resultaten",
"newVersionAvailable": "een nieuwe versie is beschikbaar"
},
"filter": {
"rating": "rating",
@@ -452,7 +459,8 @@
"saveQueueFailed": "kan wachtrij niet opslaan",
"settingsSyncError": "Er zijn verschillen gevonden tussen de instellingen in de renderer en het hoofdproces. Start de applicatie opnieuw op om de wijzigingen toe te passen",
"invalidJson": "ongeldige JSON",
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet"
"serverLockSingleServer": "slechts één server is toegestaan als server op slot is gezet",
"playbackPausedDueToError": "afspelen gepauzeerd vanwege een fout"
},
"entity": {
"genre_one": "genre",
@@ -561,7 +569,8 @@
"title": "$t(common.title)",
"titleArtist": "$t(common.title) (artiest)",
"titleCombined": "$t(common.title) (gecombineerd)",
"year": "$t(common.year)"
"year": "$t(common.year)",
"albumGroup": "albumgroep"
},
"general": {
"advancedSettings": "geavanceerde instellingen",
@@ -954,7 +963,29 @@
"automaticUpdates_description": "Zoek en installeer updates automatisch",
"releaseChannel_optionAlpha": "alfa (nachtelijk)",
"discordStateIcon": "toon afspeelicoon",
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld"
"discordStateIcon_description": "toon een klein afspeelicoon in de rich-presence-status. het gepauzeerde icoon wordt altijd getoond als \"rich presence tonen wanneer gepauzeerd\" is ingeschakeld",
"autosave": "afspeelwachtrij automatisch opslaan",
"autosave_description": "schakel in dat de afspeelwachtrij automatisch wordt opgeslagen op je server. dit is enkel mogelijk bij gebruik van Navidrome/Subsonic met een niet-gemengde afspeelwachtrij.",
"autosaveCount": "frequentie automatisch opslaan afspeelwachtrij",
"autosaveCount_description": "aantal nummerwisselingen voordat de wachtrij wordt opgeslagen. bij 1 (het minimum) treedt dit bij elke nummerwissel op",
"useThemePrimaryShade": "gebruikt primaire tint van thema",
"useThemePrimaryShade_description": "gebruik de primaire tint die in het geselecteerde thema is ingesteld voor hoofdkleurvarianten",
"primaryShade": "primaire tint",
"primaryShade_description": "overschrijf de primaire tint (0-9) die wordt gebruikt voor knoppen, links en andere elementen die de hoofdkleur gebruiken",
"listenbrainz_description": "toon links naar ListenBrainz op artiest- en albumpagina's",
"listenbrainz": "toon ListenBrainz-links",
"qobuz_description": "toon links naar Qobuz op artiest- en albumpagina's",
"qobuz": "toon Qobuz-links",
"spotify_description": "toon links naar Spotify op artiest- en albumpagina's",
"spotify": "toon Spotify-links",
"nativeSpotify_description": "open de Spotify app in plaats van de webbrowser",
"nativeSpotify": "gebruik Spotify app",
"playerItemConfiguration_description": "stel in welke items en in welke volgorde te tonen in de volledigschermspeler",
"playerItemConfiguration": "configuratie van afspeel-item",
"sidePlayQueueLayout": "uitlijning afspeelwachtrij aan zijkant",
"sidePlayQueueLayout_description": "stel de uitlijning in voor de afspeelwachtrij die aan de zijkant is gekoppeld",
"sidePlayQueueLayout_optionHorizontal": "horizontaal",
"sidePlayQueueLayout_optionVertical": "verticaal"
},
"form": {
"addServer": {
@@ -1013,8 +1044,7 @@
"editPlaylist": {
"title": "$t(entity.playlist, {\"count\": 1}) aanpassen",
"publicJellyfinNote": "Jellyfin laat niet weten of een playlist publiek of privaat is. Als u wilt dat dit publiek blijft, selecteer de volgende invoer",
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet",
"editNote": "Handmatige bewerking wordt afgeraden voor grote afspeellijsten. Weet je zeker dat je het risico op dataverlies wilt accepteren door de bestaande afspeellijst te overschrijven?"
"success": "$t(entity.playlist, {\"count\": 1}) succesvol geüpdatet"
},
"updateServer": {
"title": "update server",
@@ -1114,7 +1144,8 @@
"sleepTimer_off": "uit",
"sleepTimer_timeRemaining": "{{time}} resterend",
"sleepTimer_setCustom": "timer instellen",
"sleepTimer_cancel": "timer annuleren"
"sleepTimer_cancel": "timer annuleren",
"albumRadio": "albumradio"
},
"datetime": {
"minuteShort": "m",
+33 -6
View File
@@ -19,7 +19,10 @@
"setRating": "oceń",
"openIn": {
"lastfm": "Otwórz w Last.fm",
"musicbrainz": "Otwórz w MusicBrainz"
"musicbrainz": "Otwórz w MusicBrainz",
"listenbrainz": "Otwórz w ListenBrainz",
"qobuz": "Otwórz w Qobuz",
"spotify": "Otwórz w Spotify"
},
"moveToNext": "przesuń na następne",
"downloadStarted": "rozpoczęto pobieranie {{count}} elementów",
@@ -165,7 +168,9 @@
"filter_multiple": "multi",
"filter_single": "single",
"rename": "zmień nazwę",
"newVersionAvailable": "nowa wersja jest dostępna"
"newVersionAvailable": "nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "grupowanie"
},
"entity": {
"genre_one": "gatunek",
@@ -370,8 +375,7 @@
"editPlaylist": {
"title": "edytuj $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) zaktualizowana pomyślnie",
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję",
"editNote": "manualne edytowanie nie jest zalecane dla dużych playlist. czy na pewno zgadzasz się na ryzyko utraty danych wywołane przez nadpisanie istniejącej playlisty?"
"publicJellyfinNote": "Z jakiegoś powodu Jellyfin nie udostępnia informacji na temat publiczności playlisty. Jeżeli chcesz, aby ta pozostała publiczna, mniej wybraną poniższą opcję"
},
"shareItem": {
"allowDownloading": "zezwól na pobieranie",
@@ -417,6 +421,9 @@
"export": "eksportuj tekst",
"input_synced": "eksportuj zsynchronizowany tekst",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stacja radiowa zaktualizowana pomyślnie"
}
},
"page": {
@@ -1043,7 +1050,21 @@
"autosave": "automatycznie zapisuj kolejkę odtwarzania",
"autosave_description": "włącz automatyczne zapisywanie kolejki odtwarzania na twój serwer. to jest możliwe tylko gdy używane jest Navidrome/Subsonic, i nie masz zmixowanej kolejki odtwarzania.",
"autosaveCount": "częstotliwość automatycznego zapisywania kolejki odtwarzania",
"autosaveCount_description": "ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki"
"autosaveCount_description": "ile razy piosenka zostanie zmieniona przed zapisaniem kolejki. 1 (najmniejsze) oznacza zapisywanie kolejki po każdej zmianie piosenki",
"listenbrainz_description": "pokaż linki do ListenBrainz na stronach wykonawców/albumów",
"listenbrainz": "pokaż linki ListenBrainz",
"qobuz_description": "pokaż linki do Qobuz na stronach wykonawców/albumów",
"qobuz": "pokaż linki Qobuz",
"spotify_description": "pokaż linki do Spotify na stronach wykonawców/albumów",
"spotify": "pokaż linki Spotify",
"nativeSpotify_description": "otwieraj w aplikacji Spotify zamiast w twojej przeglądarce",
"nativeSpotify": "używaj aplikacji Spotify",
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
"sidePlayQueueLayout_optionHorizontal": "poziomy",
"sidePlayQueueLayout_optionVertical": "pionowy",
"waveformLoadingDelay": "opóźnienie załadowania fali",
"waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego."
},
"table": {
"config": {
@@ -1360,6 +1381,12 @@
},
"pasteGradient": "Wklej Gradient",
"pasteGradientPlaceholder": "Wklej tutaj JSON gradientu...",
"ansiBands": "Paski ANSI"
"ansiBands": "Paski ANSI",
"systemAudioConsentAllow": "Zezwól",
"systemAudioConsentBody": "Wizualizer wymaga dostępu do audio systemu do działania",
"systemAudioConsentDecline": "Odmów",
"systemAudioConsentTitle": "Przyznać dostęp do audio systemu?",
"systemAudioCaptureFailed": "Nie udało się rozpocząć przechwytywania: {{message}}",
"systemAudioNoAudioTrack": "Nie została zwrócona żadna ścieżka audio. Sprawdź czy przechwytywanie audio będzie włączone, gdy będzie o to prośba."
}
}
+1
View File
@@ -84,6 +84,7 @@
"size": "tamanho",
"note": "observação",
"mbid": "ID no MusicBrainz",
"grouping": "agrupamento",
"reload": "recarregar",
"codec": "codec",
"preview": "pré-visualizar",
+1
View File
@@ -72,6 +72,7 @@
"forceRestartRequired": "reinicie para aplicar as alterações… feche a notificação para reiniciar",
"forward": "para frente",
"gap": "intervalo",
"grouping": "agrupamento",
"home": "início",
"increase": "incrementar",
"left": "esquerda",
+2 -2
View File
@@ -126,6 +126,7 @@
"note": "заметка",
"none": "нет",
"mbid": "MusicBrainz ID",
"grouping": "группировка",
"reload": "перезагрузить",
"preview": "просмотр",
"codec": "кодек",
@@ -678,8 +679,7 @@
"editPlaylist": {
"title": "редактировать $t(entity.playlist, {\"count\": 1})",
"success": "$t(entity.playlist, {\"count\": 1}) обновлён успешно",
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию",
"editNote": "редактирование больших плейлистов вручную не рекомендуется. Вы уверены, что готовы принять риск потери данных, который может возникнуть в результате перезаписи существующего плейлиста?"
"publicJellyfinNote": "Jellyfin по какой-то причине не предоставляет информацию о том, публичный плейлист или нет. Если вы хотите, чтобы он остался публичным, выберите следующую опцию"
},
"shareItem": {
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
+1 -2
View File
@@ -310,8 +310,7 @@
"editPlaylist": {
"title": "திருத்து $t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "சில காரணங்களால் செல்லிஃபின் ஒரு பிளேலிச்ட் பொதுவில் இல்லையா என்பதை அம்பலப்படுத்தவில்லை. இது பொதுவில் இருக்க விரும்பினால், தயவுசெய்து பின்வரும் உள்ளீட்டைத் தேர்ந்தெடுக்கவும்",
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது",
"editNote": "பெரிய பிளேலிச்ட்களுக்கு கைமுறை திருத்தங்கள் பரிந்துரைக்கப்படவில்லை. ஏற்கனவே உள்ள பிளேலிச்ட்டை மேலெழுதுவதால் ஏற்படும் தரவு இழப்பின் அபாயத்தை நிச்சயமாக ஏற்றுக்கொள்கிறீர்களா?"
"success": "$t(entity.playlist, {\"count\": 1}) வெற்றிகரமாகப் புதுப்பிக்கப்பட்டது"
},
"lyricSearch": {
"input_artist": "$t(entity.artist, {\"count\": 1})",
+1 -1
View File
@@ -98,6 +98,7 @@
"forceRestartRequired": "перезапустіть, щоб застосувати зміни… закрийте повідомлення, щоб перезапустити",
"forward": "уперед",
"gap": "прогалина",
"grouping": "групування",
"home": "додому",
"increase": "збільшити",
"left": "ліво",
@@ -382,7 +383,6 @@
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin з якоїсь причини не показує, чи є плейлист публічним чи ні. Якщо ви хочете, щоб він залишався публічним, виберіть варіант нижче",
"editNote": "ручне редагування не рекомендується для великих плейлистів. ви впевнені, що готові прийняти ризик втрати даних, який виникає при перезапису існуючого плейлисту?",
"success": "$t(entity.playlist, {\"count\": 1}) успішно оновлено",
"title": "змінити $t(entity.playlist, {\"count\": 1})"
},
+24 -6
View File
@@ -19,7 +19,10 @@
"goToPage": "前往页面",
"openIn": {
"lastfm": "在 Last.fm 中打开",
"musicbrainz": "在 MusicBrainz 中打开"
"musicbrainz": "在 MusicBrainz 中打开",
"listenbrainz": "在 ListenBrainz 中打开",
"qobuz": "在 Qobuz 中打开",
"spotify": "在 Spotify 中打开"
},
"moveToNext": "移至下一首",
"downloadStarted": "开始下载 {{count}} 个项目",
@@ -157,7 +160,9 @@
"mood": "氛围",
"rename": "重命名",
"filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用"
"newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -448,7 +453,7 @@
"discordServeImage": "从服务器提供 {{discord}} 图像",
"discordServeImage_description": "从服务器本身分享 {{discord}} rich presence 的封面艺术,仅适用于 Jellyfin 和 Navidrome。 {{discord}} 使用机器人来获取图像,因此您的服务器必须可通过公共互联网访问",
"musicbrainz": "显示 MusicBrainz 链接",
"musicbrainz_description": "在存在 MusicBrainz ID 的艺术家/专辑页面上显示 MusicBrainz 链接",
"musicbrainz_description": "在艺术家/专辑页面上显示 MusicBrainz 链接(如果存在 MusicBrainz ID",
"lastfm": "显示 last.fm 链接",
"lastfm_description": "在艺术家/专辑页面上显示 Last.fm 的链接",
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
@@ -593,7 +598,21 @@
"primaryShade": "主色调",
"primaryShade_description": "覆盖按钮、链接和其他主色元素使用的主色调(0-9)",
"playerItemConfiguration_description": "配置全屏播放器上显示的项目及其显示顺序",
"playerItemConfiguration": "播放器项目配置"
"playerItemConfiguration": "播放器项目配置",
"listenbrainz_description": "在艺术家/专辑页面上显示 ListenBrainz 链接",
"listenbrainz": "显示 ListenBrainz 链接",
"qobuz_description": "在艺术家/专辑页面上显示 Qobuz 链接",
"qobuz": "显示 Qobuz 链接",
"spotify_description": "在艺术家/专辑页面上显示 Spotify 链接",
"spotify": "显示 Spotify 链接",
"nativeSpotify_description": "在 Spotify 应用中打开,而不是在浏览器中打开",
"nativeSpotify": "使用 Spotify 应用",
"sidePlayQueueLayout": "侧边播放队列布局",
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -945,8 +964,7 @@
"editPlaylist": {
"title": "编辑$t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
"success": "$t(entity.playlist, {\"count\": 1})更新成功",
"editNote": "不建议对大型播放列表进行手动编辑,你确定接受新播放列表覆盖已有播放列表可能导致的数据丢失风险吗?"
"success": "$t(entity.playlist, {\"count\": 1})更新成功"
},
"lyricSearch": {
"title": "搜索歌词",
+31 -9
View File
@@ -116,7 +116,9 @@
"itemsMore": "{{count}} 更多",
"filter_single": "單選",
"filter_multiple": "複選",
"newVersionAvailable": "有新的版本可供使用"
"newVersionAvailable": "有新的版本可供使用",
"numberOfResults": "{{numberOfResults}} 項結果",
"grouping": "分組"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
@@ -777,10 +779,20 @@
"autosave_description": "啟用自動將播放佇列儲存到您的伺服器。這只有在使用Navidrome/Subsonic時才可使用,並且您不能有混合播放佇列。",
"autosaveCount": "自動播放佇列儲存頻率",
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
"spotify_description": "在藝人與專輯頁面顯示Spotify的連結",
"spotify": "顯示Spotify的連結",
"nativeSpotify_description": "在Spotify應用程式中開啟,而非在瀏覽器中開啟",
"nativeSpotify": "使用Spotify應用程式"
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
"spotify": "顯示 Spotify 的連結",
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非在瀏覽器中開啟",
"nativeSpotify": "使用 Spotify 應用程式",
"sidePlayQueueLayout": "側邊播放佇列佈局",
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直",
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
"listenbrainz": "顯示 ListenBrainz 連結",
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
"qobuz": "顯示 Qobuz 連結",
"waveformLoadingDelay": "波形載入延遲",
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。"
},
"table": {
"config": {
@@ -913,7 +925,9 @@
"openIn": {
"lastfm": "在Last.fm開啟",
"musicbrainz": "在MusicBrainz開啟",
"spotify": "在Spotify中開啟"
"spotify": "在 Spotify 中開啟",
"listenbrainz": "在 ListenBrainz 中開啟",
"qobuz": "在 Qobuz 中開啟"
},
"downloadStarted": "已開始下載 {{count}} 項內容",
"moveItems": "移動項目",
@@ -1064,8 +1078,7 @@
"editPlaylist": {
"title": "編輯$t(entity.playlist, {\"count\": 1})",
"publicJellyfinNote": "Jellyfin 出於某種原因,不會顯示播放清單是否公開。如果您希望保持公開狀態,請選擇以下輸入",
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功",
"editNote": "不建議手動編輯大型播放清單,你確定要承擔覆寫現有播放清單可能造成的資料遺失風險嗎?"
"success": "$t(entity.playlist, {\"count\": 1}) 更新成功"
},
"shareItem": {
"allowDownloading": "允許下載",
@@ -1111,6 +1124,9 @@
"export": "匯出歌詞",
"input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電臺更新成功"
}
},
"releaseType": {
@@ -1319,6 +1335,12 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
"systemAudioConsentTitle": "允許存取系統音訊?"
}
}
+4 -2
View File
@@ -58,14 +58,16 @@ export async function getSearchResults(
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<LrcLibSearchResponse[]>;
if (!params.name) {
if (!params.name && !params.artist) {
return null;
}
const searchQuery = [params.name, params.artist].join(' ');
try {
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
params: {
q: params.name,
q: searchQuery,
},
});
} catch (e) {
+10 -2
View File
@@ -437,10 +437,18 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
try {
return getMpvInstance()?.getTimePosition();
const mpv = getMpvInstance();
if (!mpv) {
return undefined;
}
return await mpv.getTimePosition();
} catch (err: any | NodeMpvError) {
// Err 3: IPC command invalid — e.g. time-pos unavailable when idle / between tracks
if (err?.errcode === 3) {
return undefined;
}
mpvLog({ action: `Failed to get current time` }, err);
return 0;
return undefined;
}
});
+1
View File
@@ -40,6 +40,7 @@ export const store = new Store<any>({
playbackType: 'web',
should_prompt_accessibility: true,
shown_accessibility_warning: false,
visualizer_system_audio_consent_granted: false,
window_enable_tray: true,
window_exit_to_tray: false,
window_minimize_to_tray: false,
+8 -1
View File
@@ -1,2 +1,9 @@
import './core';
import(`./${process.platform}`);
if (process.platform === 'linux') {
import('./linux');
} else if (process.platform === 'darwin') {
import('./darwin');
} else if (process.platform === 'win32') {
import('./win32');
}
+17
View File
@@ -150,6 +150,23 @@ ipcMain.on(
return;
}
// If the served id is an empty string, this is a radio
// Use a limited subset of the fields
if (song._serverId === '') {
// The id as passed in from use-mpris is radio- plus the radio ID
// If there are spaces or some other characters, this causes MPRIS to error and
// disconnect the bus. To prevent this, just use a fake track/radio
mprisPlayer.metadata = {
'mpris:trackid': mprisPlayer.objectPath(`track/radio`),
'xesam:album': song.album || null,
'xesam:artist': song.artists?.length
? song.artists.map((artist) => artist.name)
: null,
'xesam:title': song.name || null,
};
return;
}
mprisPlayer.metadata = {
'mpris:artUrl': imageUrl || null,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
+1
View File
@@ -0,0 +1 @@
export {};
+124 -26
View File
@@ -29,7 +29,7 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import MenuBuilder from './menu';
import MenuBuilder, { MenuPlaybackState } from './menu';
import {
autoUpdaterLogInterface,
createLog,
@@ -41,7 +41,7 @@ import {
} from './utils';
import './features';
import { PlayerType, TitleTheme } from '/@/shared/types/types';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: {
bucket: string;
@@ -272,16 +272,18 @@ if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
app.commandLine.appendSwitch('password-store', passwordStore);
}
// Handle fractional scaling issue from Wayland https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
if (isLinux()) {
app.commandLine.appendSwitch('disable-features', 'WaylandFractionalScaleV1');
}
let mainWindow: BrowserWindow | null = null;
let tray: null | Tray = null;
let exitFromTray = false;
let forceQuit = false;
let powerSaveBlockerId: null | number = null;
let menuBuilder: MenuBuilder | null = null;
let currentPlaybackStatus: PlayerStatus = PlayerStatus.PAUSED;
let currentPrivateMode = false;
let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
@@ -338,6 +340,23 @@ export const getMainWindow = () => {
return mainWindow;
};
const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
shuffleEnabled: currentShuffleEnabled,
sidebarCollapsed: currentSidebarCollapsed,
});
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
};
export const sendToastToRenderer = ({
message,
type,
@@ -436,19 +455,21 @@ const createTray = () => {
},
]);
tray.on('click', () => {
if (store.get('window_minimize_to_tray')) {
if (mainWindow?.isVisible()) {
mainWindow?.hide();
if (!isMacOS()) {
tray.on('click', () => {
if (store.get('window_minimize_to_tray')) {
if (mainWindow?.isVisible()) {
mainWindow?.hide();
} else {
mainWindow?.show();
createWinThumbarButtons();
}
} else {
mainWindow?.show();
createWinThumbarButtons();
}
} else {
mainWindow?.show();
createWinThumbarButtons();
}
});
});
}
tray.setToolTip('Feishin');
tray.setContextMenu(contextMenu);
@@ -702,12 +723,8 @@ async function createWindow(first = true): Promise<void> {
});
}
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
menuBuilder = new MenuBuilder(mainWindow);
rebuildMainMenu();
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
@@ -715,6 +732,10 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
callback({ audio: 'loopback' });
});
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
@@ -745,11 +766,17 @@ const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
const shouldDisableMediaFeatures =
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
const chromiumDisabledFeatures: string[] = [];
// Fractional scaling on Wayland: https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
if (isLinux()) {
chromiumDisabledFeatures.push('WaylandFractionalScaleV1');
}
if (shouldDisableMediaFeatures) {
app.commandLine.appendSwitch(
'disable-features',
'HardwareMediaKeyHandling,MediaSessionService',
);
chromiumDisabledFeatures.push('HardwareMediaKeyHandling', 'MediaSessionService');
}
if (chromiumDisabledFeatures.length > 0) {
app.commandLine.appendSwitch('disable-features', chromiumDisabledFeatures.join(','));
}
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
@@ -779,6 +806,17 @@ enum BindingActions {
VOLUME_UP = 'volumeUp',
}
const getMenuAccelerator = (
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
action: BindingActions,
) => {
const hotkey = data[action]?.hotkey;
if (!hotkey) return undefined;
return hotkeyToElectronAccelerator(hotkey);
};
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
@@ -832,6 +870,26 @@ ipcMain.on(
}
}
playbackMenuAccelerators = {
next: getMenuAccelerator(data, BindingActions.NEXT),
playPause:
getMenuAccelerator(data, BindingActions.PLAY_PAUSE) ||
getMenuAccelerator(data, BindingActions.PLAY) ||
getMenuAccelerator(data, BindingActions.PAUSE),
previous: getMenuAccelerator(data, BindingActions.PREVIOUS),
repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT),
seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD),
seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD),
shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE),
stop: getMenuAccelerator(data, BindingActions.STOP),
volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN),
volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP),
};
if (isMacOS()) {
rebuildMainMenu();
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
@@ -972,3 +1030,43 @@ if (!ipcMain.eventNames().includes('open-application-directory')) {
shell.openPath(userDataPath);
});
}
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentPlaybackStatus = status;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentRepeatMode = repeat;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentShuffleEnabled = shuffle;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-private-mode', (_event, privateMode: boolean) => {
currentPrivateMode = privateMode;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => {
currentSidebarCollapsed = collapsedSidebar;
if (!isMacOS()) return;
rebuildMainMenu();
});
+190 -4
View File
@@ -1,18 +1,53 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';
import packageJson from '../../package.json';
import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types';
export type MenuPlaybackState = {
accelerators?: {
next?: string;
playPause?: string;
previous?: string;
repeat?: string;
seekBackward?: string;
seekForward?: string;
shuffle?: string;
stop?: string;
volumeDown?: string;
volumeUp?: string;
};
playbackStatus?: PlayerStatus;
privateMode?: boolean;
repeatMode?: PlayerRepeat;
shuffleEnabled?: boolean;
sidebarCollapsed?: boolean;
};
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
developmentEnvironmentSetup = false;
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
buildDarwinTemplate({
accelerators,
playbackStatus = PlayerStatus.PAUSED,
privateMode = false,
repeatMode = PlayerRepeat.NONE,
shuffleEnabled = false,
sidebarCollapsed = false,
}: MenuPlaybackState = {}): MenuItemConstructorOptions[] {
const isPlaying = playbackStatus === PlayerStatus.PLAYING;
const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE;
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
@@ -29,6 +64,21 @@ export default class MenuBuilder {
label: 'Settings',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-manage-servers');
},
label: 'Manage servers',
},
{
checked: privateMode,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-private-mode');
},
label: 'Private session',
type: 'checkbox',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
@@ -71,6 +121,22 @@ export default class MenuBuilder {
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Command+R',
click: () => {
@@ -97,6 +163,22 @@ export default class MenuBuilder {
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+K',
click: () => {
this.mainWindow.webContents.send('renderer-open-command-palette');
},
label: 'Command Palette...',
},
{
checked: sidebarCollapsed,
click: () => {
this.mainWindow.webContents.send('renderer-toggle-sidebar');
},
label: 'Collapse sidebar',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Command+F',
click: () => {
@@ -119,6 +201,89 @@ export default class MenuBuilder {
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuPlayback: MenuItemConstructorOptions = {
label: 'Playback',
submenu: [
{
accelerator: accelerators?.playPause,
click: () => {
this.mainWindow.webContents.send('renderer-player-play-pause');
},
label: isPlaying ? 'Pause' : 'Play',
},
{ type: 'separator' },
{
accelerator: accelerators?.next,
click: () => {
this.mainWindow.webContents.send('renderer-player-next');
},
label: 'Next',
},
{
accelerator: accelerators?.previous,
click: () => {
this.mainWindow.webContents.send('renderer-player-previous');
},
label: 'Previous',
},
{
accelerator: accelerators?.seekForward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-forward');
},
label: 'Seek Forward',
},
{
accelerator: accelerators?.seekBackward,
click: () => {
this.mainWindow.webContents.send('renderer-player-skip-backward');
},
label: 'Seek Backforward',
},
{ type: 'separator' },
{
accelerator: accelerators?.shuffle,
checked: shuffleEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-shuffle');
},
label: 'Shuffle',
type: 'checkbox',
},
{
accelerator: accelerators?.repeat,
checked: isRepeatEnabled,
click: () => {
this.mainWindow.webContents.send('renderer-player-toggle-repeat');
},
label: 'Repeat',
type: 'checkbox',
},
{ type: 'separator' },
{
accelerator: accelerators?.stop,
click: () => {
this.mainWindow.webContents.send('renderer-player-stop');
},
label: 'Stop',
},
{ type: 'separator' },
{
accelerator: accelerators?.volumeUp,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-up');
},
label: 'Volume Up',
},
{
accelerator: accelerators?.volumeDown,
click: () => {
this.mainWindow.webContents.send('renderer-player-volume-down');
},
label: 'Volume Down',
},
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
@@ -148,6 +313,13 @@ export default class MenuBuilder {
},
label: 'Search Issues',
},
{ type: 'separator' },
{
click: () => {
this.mainWindow.webContents.send('renderer-open-release-notes');
},
label: 'Version ' + packageJson.version,
},
],
};
@@ -156,7 +328,14 @@ export default class MenuBuilder {
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
return [
subMenuAbout,
subMenuEdit,
subMenuView,
subMenuPlayback,
subMenuWindow,
subMenuHelp,
];
}
buildDefaultTemplate(): MenuItemConstructorOptions[] {
@@ -262,14 +441,14 @@ export default class MenuBuilder {
return templateDefault;
}
buildMenu(): Menu {
buildMenu(playbackState: MenuPlaybackState = {}): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
? this.buildDarwinTemplate(playbackState)
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
@@ -279,6 +458,13 @@ export default class MenuBuilder {
}
setupDevelopmentEnvironment(): void {
// buildMenu can run multiple times as menu state updates; attach this once.
if (this.developmentEnvironmentSetup) {
return;
}
this.developmentEnvironmentSetup = true;
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
+2 -1
View File
@@ -1,4 +1,5 @@
import { SetActivity } from '@xhayper/discord-rpc';
import type { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
+25
View File
@@ -65,6 +65,26 @@ const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-settings', cb);
};
const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-command-palette', cb);
};
const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-manage-servers', cb);
};
const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-private-mode', cb);
};
const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-toggle-sidebar', cb);
};
const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-open-release-notes', cb);
};
export const utils = {
checkForUpdates,
disableAutoUpdates,
@@ -78,7 +98,12 @@ export const utils = {
openApplicationDirectory,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
rendererOpenSettings,
rendererTogglePrivateMode,
rendererToggleSidebar,
};
export type Utils = typeof utils;
+104 -1
View File
@@ -10,6 +10,8 @@ import {
ControllerEndpoint,
InternalControllerEndpoint,
ServerType,
SetPlaylistSongsArgs,
SetPlaylistSongsResponse,
} from '/@/shared/types/domain-types';
type ApiController = {
@@ -67,6 +69,7 @@ const getPathReplaceSettings = () => {
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
const pathSettings = getPathReplaceSettings();
return {
...args,
context: {
@@ -144,6 +147,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -172,6 +189,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
);
}
return apiController(
'deleteInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -186,6 +217,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`,
);
}
return apiController(
'deletePlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getAlbumArtistDetail(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -717,7 +762,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
return '';
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
);
}
return apiController(
@@ -885,6 +932,20 @@ export const controller: GeneralController = {
}),
);
},
setPlaylistSongs: function (args: SetPlaylistSongsArgs): Promise<SetPlaylistSongsResponse> {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setPlaylistSongs`,
);
}
return apiController(
'setPlaylistSongs',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
setRating(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -941,4 +1002,46 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
);
}
return apiController(
'uploadInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadPlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`,
);
}
return apiController(
'uploadPlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
};
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getStreamUrl: ({ apiClientProps: { server }, query }) => {
getStreamUrl: async ({ apiClientProps: { server }, query }) => {
const { bitrate, format, id, transcode } = query;
const deviceId = '';
@@ -1769,6 +1769,24 @@ export const JellyfinController: InternalControllerEndpoint = {
),
};
},
setPlaylistSongs: async (args) => {
const { apiClientProps, body } = args;
const res = await jfApiClient(apiClientProps).updatePlaylist({
body: {
Ids: body.songIds,
},
params: {
id: body.id,
},
});
if (res.status !== 204) {
throw new Error('Failed to update playlist songs');
}
return null;
},
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
@@ -1798,14 +1816,8 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).updatePlaylist({
body: {
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
IsPublic: body.public,
MediaType: 'Audio',
Name: body.name,
PremiereDate: null,
ProviderIds: {},
Tags: [],
UserId: apiClientProps.server?.userId, // Required
},
params: {
id: query.id,
@@ -1820,31 +1832,6 @@ export const JellyfinController: InternalControllerEndpoint = {
},
};
// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
// const { query, apiClientProps } = args;
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
// query: {
// Limit: query.limit,
// ParentId: query.musicFolderId,
// Recursive: true,
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
// StartIndex: query.startIndex,
// },
// });
// if (res.status !== 200) {
// throw new Error('Failed to get artist list');
// }
// return {
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
// startIndex: query.startIndex,
// totalRecordCount: res.body.TotalRecordCount,
// };
// };
function getLibraryId(musicFolderId?: string | string[]) {
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
}
@@ -46,6 +46,33 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStationImage: {
body: null,
method: 'DELETE',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
@@ -55,6 +82,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'artist/:id',
@@ -132,6 +168,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
getRadioList: {
method: 'GET',
path: 'radio',
query: ndType._parameters.radioList,
responses: {
200: resultWithHeaders(ndType._response.radioList),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
@@ -205,6 +250,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
updateInternetRadioStation: {
body: ndType._parameters.updateInternetRadioStation,
method: 'PUT',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.updateInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
@@ -214,6 +268,33 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadPlaylistImage: {
body: ndType._parameters.uploadPlaylistImage,
method: 'POST',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadPlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
});
const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval';
import orderBy from 'lodash/orderBy';
@@ -5,13 +6,19 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint,
playlistListSortMap,
@@ -23,6 +30,12 @@ import {
SortOrder,
sortOrderMap,
tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
userListSortMap,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -30,6 +43,14 @@ import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [
// Why 2? Subsonic controller will return 1 for its own implementation
// Use 2 to denote that Navidrome's own API has a different endpoint
[
'0.61.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
],
['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
@@ -170,8 +191,54 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation,
deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station');
}
return null;
},
deleteInternetRadioStationImage: async (
args: DeleteInternetRadioStationImageArgs,
): Promise<DeleteInternetRadioStationImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station image');
}
return res.body.data.status === 'ok';
},
deletePlaylist: async (args) => {
const { apiClientProps, query } = args;
@@ -187,6 +254,23 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete playlist image');
}
return res.body.data.status === 'ok';
},
getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -233,8 +317,8 @@ export const NavidromeController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: null,
imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null,
imageId: artist.id,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null,
@@ -547,7 +631,24 @@ export const NavidromeController: InternalControllerEndpoint = {
},
getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations,
getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getRadioList({
query: {
_end: -1,
_order: 'ASC',
_sort: NDRadioListSort.NAME,
_start: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get internet radio stations');
}
return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
},
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => {
@@ -604,6 +705,7 @@ export const NavidromeController: InternalControllerEndpoint = {
query: {
_end: -1,
_order: 'ASC',
_sort: NDSongListSort.ID,
_start: 0,
...excludeMissing(apiClientProps.server),
},
@@ -744,7 +846,6 @@ export const NavidromeController: InternalControllerEndpoint = {
args.context?.pathReplaceWith,
);
},
getSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -818,6 +919,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: albums.totalRecordCount,
};
},
getSongListCount: async ({ apiClientProps, query }) =>
NavidromeController.getSongList({
apiClientProps,
@@ -1010,6 +1112,7 @@ export const NavidromeController: InternalControllerEndpoint = {
query: {
_end: -1,
_order: 'ASC',
_sort: NDSongListSort.ID,
_start: 0,
...excludeMissing(apiClientProps.server),
},
@@ -1120,6 +1223,7 @@ export const NavidromeController: InternalControllerEndpoint = {
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setPlaylistSongs: SubsonicController.setPlaylistSongs,
setRating: SubsonicController.setRating,
shareItem: async (args) => {
const { apiClientProps, body } = args;
@@ -1142,7 +1246,26 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
updateInternetRadioStation: SubsonicController.updateInternetRadioStation,
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
body: {
homePageUrl: body.homepageUrl ?? '',
name: body.name,
streamUrl: body.streamUrl,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update internet radio station');
}
return null;
},
updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
@@ -1167,4 +1290,110 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/radio/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload internet radio station image');
}
return res.data?.status === 'ok';
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/playlist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload playlist image');
}
return res.data?.status === 'ok';
},
};
+5
View File
@@ -347,6 +347,11 @@ export const queryKeys: Record<
list: (serverId: string) => [serverId, 'roles'] as const,
},
search: {
infiniteList: (
serverId: string,
type: 'albumArtists' | 'albums' | 'songs',
searchTerm: string,
) => [serverId, 'search', 'infiniteList', type, searchTerm] as const,
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
+51 -9
View File
@@ -250,6 +250,23 @@ export const contract = c.router({
200: ssType._response.topSongsList,
},
},
getTranscodeDecision: {
body: ssType._body.getTranscodeDecision,
method: 'POST',
path: 'getTranscodeDecision.view',
query: ssType._parameters.getTranscodeDecision,
responses: {
200: ssType._response.getTranscodeDecision,
},
},
getTranscodeStream: {
method: 'GET',
path: 'getTranscodeStream.view',
query: ssType._parameters.getTranscodeStream,
responses: {
200: z.string(),
},
},
getUser: {
method: 'GET',
path: 'getUser.view',
@@ -392,7 +409,7 @@ export const ssApiClient = (args: {
const { server, signal, silent, url } = args;
return initClient(contract, {
api: async ({ headers, method, path }) => {
api: async ({ body, headers, method, path, rawQuery }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
@@ -423,19 +440,44 @@ export const ssApiClient = (args: {
url: `${baseUrl}/${api}`,
};
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
const isGetTranscodeDecisionPost =
method === 'POST' && api === 'getTranscodeDecision.view';
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
if (isGetTranscodeDecisionPost && body != null) {
request.method = 'POST';
request.headers = {
...headers,
'Content-Type': 'application/json',
};
request.data = body;
request.params = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...(typeof rawQuery === 'object' && rawQuery !== null
? (rawQuery as Record<string, unknown>)
: {}),
};
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
request.method = 'POST';
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
} else {
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
request.method = method;
request.params = data;
}
+262 -12
View File
@@ -8,7 +8,12 @@ import md5 from 'md5';
import { z } from 'zod';
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import {
getDefaultTranscodingProfiles,
getDirectPlayProfiles,
} from '/@/renderer/features/player/components/audio-players';
import { randomString } from '/@/renderer/utils';
import { logFn } from '/@/renderer/utils/logger';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import {
@@ -87,6 +92,172 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
const MAX_SUBSONIC_ITEMS = 500;
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
// {
// audioCodecs: ['mp3'],
// containers: ['mp3'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['aac'],
// containers: ['m4a', 'mp4'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['vorbis'],
// containers: ['ogg'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['opus'],
// containers: ['ogg', 'webm'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['pcm'],
// containers: ['wav'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['flac'],
// containers: ['flac'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// ];
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["alac"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["ac3", "eac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["ogg"],
// audioCodecs: ["flac", "speex"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wav"],
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["mkv"],
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["avi"],
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["asf", "wma"],
// audioCodecs: ["wma", "pcm", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["caf"],
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["3gp"],
// audioCodecs: ["aac", "amr"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["amr"],
// audioCodecs: ["amr"],
// protocols: ["http"],
// maxAudioChannels: 1
// },
// {
// containers: ["ape"],
// audioCodecs: ["ape"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wv"],
// audioCodecs: ["wavpack"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["ac3"],
// audioCodecs: ["ac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["eac3"],
// audioCodecs: ["eac3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["dts"],
// audioCodecs: ["dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// }
// ];
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
let streamUrl = url;
if (format) {
streamUrl += `&format=${format}`;
}
if (bitrate !== undefined) {
streamUrl += `&maxBitRate=${bitrate}`;
}
return streamUrl;
}
function buildGetTranscodeStreamUrl(
server: null | undefined | { credential?: string; url?: string },
args: {
mediaId: string;
mediaType: 'podcast' | 'song';
offset: number;
transcodeParams: string;
},
): string {
const params = new URLSearchParams({
c: 'Feishin',
mediaId: args.mediaId,
mediaType: args.mediaType,
offset: String(args.offset),
transcodeParams: args.transcodeParams,
v: '1.13.0',
});
return `${server?.url}/rest/getTranscodeStream.view?${params.toString()}&${server?.credential}`;
}
function sortAndPaginate<T>(
items: T[],
options: {
@@ -337,7 +508,7 @@ export const SubsonicController: InternalControllerEndpoint = {
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: null,
imageId: artist.coverArt ?? artist.id,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
@@ -1035,7 +1206,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
},
getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const sortOrder = (query.sortOrder || SortOrder.ASC).toLowerCase() as 'asc' | 'desc';
const res = await ssApiClient(apiClientProps).getPlaylists({});
@@ -1273,6 +1444,10 @@ export const SubsonicController: InternalControllerEndpoint = {
}
}
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
features.osTranscodeDecision = [1];
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = [1];
}
@@ -1801,20 +1976,79 @@ export const SubsonicController: InternalControllerEndpoint = {
return totalRecordCount;
},
getStreamUrl: ({ apiClientProps: { server }, query }) => {
const { bitrate, format, id, transcode } = query;
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
getStreamUrl: async ({ apiClientProps, query }) => {
const { server } = apiClientProps;
const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query;
const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
// If transcoding is explicitly enabled, just return the direct transcoded stream URL
if (transcode) {
if (format) {
url += `&format=${format}`;
}
if (bitrate !== undefined) {
url += `&maxBitRate=${bitrate}`;
}
return appendTranscodeParams(streamUrl, format, bitrate);
}
return url;
// Used in cases where MPV is the default player, since mpv handles basically every audio format
if (skipAutoTranscode) {
return streamUrl;
}
// If the server supports transcoding decision, always use it to determine if we need to transcode
if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) {
const maxTranscodingAudioBitrate = 0;
const directPlayProfiles = getDirectPlayProfiles();
const transcodingProfiles = getDefaultTranscodingProfiles();
const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({
body: {
codecProfiles: [],
directPlayProfiles,
maxAudioBitrate: 0,
maxTranscodingAudioBitrate,
name: 'Feishin',
platform: navigator.userAgent,
transcodingProfiles,
},
query: {
mediaId: id,
mediaType,
},
});
// If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player
if (transcodeDecision.status !== 200) {
logFn.error(
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
);
return streamUrl;
}
const td = transcodeDecision.body.transcodeDecision;
const requiresTranscoding = !td?.canDirectPlay;
// If the server does not require transcoding, just return the direct stream URL
if (!requiresTranscoding) {
return streamUrl;
}
logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`);
// If the server does not return transcode params, manually create the transcode params
if (!td.transcodeParams) {
return appendTranscodeParams(streamUrl, format, bitrate);
}
const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, {
mediaId: String(id),
mediaType: (mediaType ?? 'song') as 'podcast' | 'song',
offset: 0,
transcodeParams: td.transcodeParams,
});
return transcodeStreamUrl;
}
return streamUrl;
},
getStructuredLyrics: async (args) => {
const { apiClientProps, query } = args;
@@ -2118,6 +2352,22 @@ export const SubsonicController: InternalControllerEndpoint = {
),
};
},
setPlaylistSongs: async (args) => {
const { apiClientProps, body } = args;
const res = await ssApiClient(apiClientProps).createPlaylist({
query: {
playlistId: body.id,
songId: body.songIds,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist songs');
}
return null;
},
setRating: async (args) => {
const { apiClientProps, query } = args;
+104 -63
View File
@@ -7,12 +7,12 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import isElectron from 'is-electron';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -22,12 +22,7 @@ import { WebAudio } from '/@/shared/types/types';
import '/@/shared/styles/global.css';
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
const ReleaseNotesModal = lazy(() =>
import('./release-notes-modal').then((module) => ({
default: module.ReleaseNotesModal,
})),
);
import { ReleaseNotesModal } from '/@/renderer/release-notes-modal';
const UpdateAvailableDialog = lazy(() =>
import('./update-available-dialog').then((module) => ({
@@ -38,67 +33,26 @@ const UpdateAvailableDialog = lazy(() =>
const ipc = isElectron() ? window.api.ipc : null;
export const App = () => {
return <ThemedApp />;
};
const ThemedApp = () => {
const { mode, theme } = useAppTheme();
const language = useLanguage();
const { content, enabled } = useCssSettings();
const { bindings } = useHotkeySettings();
const cssRef = useRef<HTMLStyleElement | null>(null);
useSyncSettingsToMain();
useCheckForUpdates();
return (
<MantineProvider forceColorScheme={mode} theme={theme}>
<AppShell />
</MantineProvider>
);
};
const AppShell = memo(function AppShell() {
const [webAudio, setWebAudio] = useState<WebAudio>();
useEffect(() => {
if (enabled && content) {
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
cssRef.current!.textContent = '';
};
}
return () => {};
}, [content, enabled]);
const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio };
}, [webAudio]);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
const notificationStyles = useMemo(
() => ({
root: {
@@ -109,7 +63,8 @@ export const App = () => {
);
return (
<MantineProvider forceColorScheme={mode} theme={theme}>
<>
<AppEffects />
<Notifications
containerWidth="300px"
position="bottom-center"
@@ -122,10 +77,96 @@ export const App = () => {
<AppRouter />
</PlayerProvider>
</WebAudioContext.Provider>
<ReleaseNotesModal />
<Suspense fallback={null}>
<ReleaseNotesModal />
<UpdateAvailableDialog />
</Suspense>
</MantineProvider>
</>
);
});
const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<NativeMenuSyncEffect />
</>
);
const SyncSettingsEffect = () => {
useSyncSettingsToMain();
return null;
};
const UpdateCheckEffect = () => {
useCheckForUpdates();
return null;
};
const CssSettingsEffect = () => {
const { content, enabled } = useCssSettings();
const cssRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
if (!enabled || !content) {
if (cssRef.current) {
cssRef.current.textContent = '';
}
return;
}
// Yes, CSS is sanitized here as well. Prevent a user from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
if (cssRef.current) {
cssRef.current.textContent = '';
}
};
}, [content, enabled]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
return null;
};
const LanguageEffect = () => {
const language = useLanguage();
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
return null;
};
const NativeMenuSyncEffect = () => {
useNativeMenuSync();
return null;
};
@@ -67,10 +67,19 @@
padding: var(--theme-spacing-md);
}
.single-carousel-container .carousel {
min-height: 240px;
}
.single-carousel-container .carousel-item {
min-height: 240px;
}
.single-carousel-container .carousel-item .content {
flex-direction: row;
gap: var(--theme-spacing-lg);
gap: var(--theme-spacing-md);
align-items: flex-end;
min-height: 240px;
padding: var(--theme-spacing-xl);
}
@@ -36,12 +36,16 @@
min-width: 0;
}
.grid-carousel-viewport {
width: 100%;
min-height: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
gap: var(--theme-spacing-md);
contain: layout paint;
content-visibility: auto;
overflow: hidden;
will-change: transform;
}
+345 -309
View File
@@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useShowRatings } from '/@/renderer/store';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatDurationString,
formatPartialIsoDateUTC,
formatRating,
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
@@ -169,6 +169,292 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
showRating: boolean;
}
type ItemCardData = NonNullable<ItemCardProps['data']>;
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
controls,
data,
enableExpansion,
enableImageViewport = true,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
showRating,
variant,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableImageViewport?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
showRating: boolean;
variant: 'default' | 'poster';
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={(data as { imageId?: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl?: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
{...(variant === 'poster' ? { internalState } : {})}
item={data}
itemType={itemType}
showRating={showRating}
type={variant}
/>
)}
</AnimatePresence>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
controls,
data,
enableExpansion,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
rows,
showRating,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
rows: DataRow[];
showRating: boolean;
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
const CompactItemCard = ({
controls,
data,
@@ -185,7 +471,6 @@ const CompactItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -297,18 +582,6 @@ const CompactItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -338,81 +611,6 @@ const CompactItemCard = ({
e.stopPropagation();
};
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return (
<div
className={clsx(styles.container, styles.compact, {
@@ -421,31 +619,24 @@ const CompactItemCard = ({
})}
ref={ref}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<CompactItemCardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
rows={rows}
showRating={showRating}
withControls={withControls}
/>
</div>
);
}
@@ -491,7 +682,6 @@ const DefaultItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -538,18 +728,6 @@ const DefaultItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -579,93 +757,30 @@ const DefaultItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
showRating={showRating}
type="default"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="default"
withControls={withControls}
/>
<div className={styles.detailContainer}>
{rows
.filter(
@@ -728,7 +843,6 @@ const PosterItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data)
@@ -840,18 +954,6 @@ const PosterItemCard = ({
if (data) {
const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
@@ -881,63 +983,6 @@ const PosterItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="poster"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, styles.poster, {
@@ -946,31 +991,24 @@ const PosterItemCard = ({
})}
ref={ref}
>
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<ItemCardStandardImageArea
controls={controls}
data={data}
enableExpansion={enableExpansion}
enableNavigation={enableNavigation}
handleContextMenu={handleContextMenu}
handleImageClick={handleImageClick}
handleLinkDragStart={handleLinkDragStart}
imageAsLink={imageAsLink}
imageFetchPriority={imageFetchPriority}
internalState={internalState}
isRound={isRound}
itemType={itemType}
navigationPath={navigationPath}
showRating={showRating}
variant="poster"
withControls={withControls}
/>
{data && (
<div className={styles.detailContainer}>
{rows
@@ -1161,12 +1199,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
},
{
format: (data) => {
if ('releaseYear' in data && data.releaseYear !== null) {
if ('releaseYear' in data && data.releaseYear != null) {
const releaseYear = data.releaseYear;
const originalYear =
'originalYear' in data && data.originalYear !== null
? data.originalYear
: null;
'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -1186,10 +1222,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
data.originalDate &&
data.originalDate !== data.releaseDate
) {
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
}
return `${formatDateAbsoluteUTC(data.releaseDate)}`;
return `${formatPartialIsoDateUTC(data.releaseDate)}`;
}
return '';
},
@@ -0,0 +1,30 @@
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { TableColumn } from '/@/shared/types/types';
const LAYOUT_FILL_COLUMN: ItemTableListColumnConfig = {
align: 'start',
autoSize: true,
id: TableColumn.LAYOUT_FILL,
isEnabled: true,
pinned: null,
width: 0,
};
export const appendLayoutFillColumn = (
columns: ItemTableListColumnConfig[],
autoFitColumns: boolean,
): ItemTableListColumnConfig[] => {
if (autoFitColumns || columns.length === 0) {
return columns;
}
const unpinnedEnabled = columns.filter((c) => c.pinned === null && c.isEnabled !== false);
if (unpinnedEnabled.length === 0) {
return columns;
}
if (unpinnedEnabled.some((c) => c.autoSize === true)) {
return columns;
}
return [...columns, LAYOUT_FILL_COLUMN];
};
@@ -40,6 +40,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const playerRef = useRef(player);
const setFavoriteRef = useRef(setFavorite);
const setRatingRef = useRef(setRating);
playerRef.current = player;
setFavoriteRef.current = setFavorite;
setRatingRef.current = setRating;
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
@@ -266,14 +273,14 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
player.addToQueueByData(songsToAdd, playType, item.id);
playerRef.current.addToQueueByData(songsToAdd, playType, item.id);
return;
}
if (itemType === LibraryItem.QUEUE_SONG) {
const queueSong = item as QueueSong;
if (queueSong._uniqueId) {
player.mediaPlay(queueSong._uniqueId);
playerRef.current.mediaPlay(queueSong._uniqueId);
}
}
},
@@ -316,7 +323,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
setFavorite(item._serverId, [item.id], apiItemType, favorite);
setFavoriteRef.current(item._serverId, [item.id], apiItemType, favorite);
},
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
@@ -394,7 +401,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
playerRef.current.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: ({
@@ -420,20 +427,12 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
newRating = 0;
}
setRating(item._serverId, [item.id], apiItemType, newRating);
setRatingRef.current(item._serverId, [item.id], apiItemType, newRating);
},
...overrides,
};
}, [
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
player,
setFavorite,
setRating,
]);
}, [enableMultiSelect, overrides, onColumnReordered, onColumnResized]);
return controls;
};
@@ -349,9 +349,12 @@ export const useItemListInfiniteLoader = ({
mutationKey: getListRefreshMutationKey(eventKey),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const refresh = useCallback(
async (force?: boolean) => refreshMutation.mutateAsync(force),
[refreshMutation],
async (force?: boolean) => refreshMutationRef.current.mutateAsync(force),
[],
);
const updateItems = useCallback(
@@ -383,7 +386,7 @@ export const useItemListInfiniteLoader = ({
return;
}
refreshMutation.mutate(true);
refreshMutationRef.current.mutate(true);
};
eventEmitter.on('ITEM_LIST_REFRESH', handleRefresh);
@@ -391,7 +394,7 @@ export const useItemListInfiniteLoader = ({
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [eventKey, refreshMutation]);
}, [eventKey]);
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -5,7 +5,7 @@ import {
useSuspenseQuery,
UseSuspenseQueryOptions,
} from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useListContext } from '/@/renderer/context/list-context';
@@ -115,6 +115,9 @@ export const useItemListPaginatedLoader = ({
mutationKey: getListRefreshMutationKey(eventKey ?? 'paginated'),
});
const refreshMutationRef = useRef(refreshMutation);
refreshMutationRef.current = refreshMutation;
const updateItems = useCallback(
(indexes: number[], value: object) => {
return queryClient.setQueryData(
@@ -153,7 +156,7 @@ export const useItemListPaginatedLoader = ({
return;
}
refreshMutation.mutate(true);
refreshMutationRef.current.mutate(true);
};
const handleFavorite = (payload: UserFavoriteEventPayload) => {
@@ -220,7 +223,7 @@ export const useItemListPaginatedLoader = ({
eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating);
};
}, [data, eventKey, itemType, refreshMutation, serverId, updateItems]);
}, [data, eventKey, itemType, serverId, updateItems]);
return { data: data?.items || [], pageCount, totalItemCount };
};
@@ -67,6 +67,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.ID]: null,
[TableColumn.IMAGE]: null,
[TableColumn.LAST_PLAYED]: 'lastPlayedAt',
[TableColumn.LAYOUT_FILL]: null,
[TableColumn.OWNER]: null,
[TableColumn.PATH]: null,
[TableColumn.PLAY_COUNT]: 'playCount',
@@ -1,6 +1,21 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
import { formatPartialIsoDateUTC } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>;
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
const row = song as typeof song & { originalDate?: null | string };
const releaseDate = row.releaseDate;
if (!releaseDate) {
return <>&nbsp;</>;
}
const originalDate =
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
if (originalDate) {
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
}
return formatPartialIsoDateUTC(releaseDate);
};
@@ -179,6 +179,14 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.track-header-cell:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
@@ -489,9 +489,9 @@ const MetadataSection = memo(
let releaseStr = '';
if (item.releaseDate) {
if (item.originalDate && item.originalDate !== item.releaseDate) {
releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`;
releaseStr = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`;
} else {
releaseStr = formatDateAbsoluteUTC(item.releaseDate);
releaseStr = formatPartialIsoDateUTC(item.releaseDate);
}
} else if (item.releaseYear != null) {
releaseStr = String(item.releaseYear);
@@ -911,8 +911,7 @@ const DetailListHeaderCell = memo(
const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0;
const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId);
const currentWidth = col?.width ?? (fixedWidth || 100);
const showResizeHandle =
enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized;
const showResizeHandle = enableColumnResize && !isFixedColumn;
useEffect(() => {
if (!containerRef.current || !onColumnReordered) {
@@ -1026,6 +1025,7 @@ const DetailListHeaderCell = memo(
{showResizeHandle && (
<DetailListColumnResizeHandle
columnId={columnId}
disabled={!!col?.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -1040,6 +1040,7 @@ DetailListHeaderCell.displayName = 'DetailListHeaderCell';
interface DetailListColumnResizeHandleProps {
columnId: TableColumn;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -1047,6 +1048,7 @@ interface DetailListColumnResizeHandleProps {
const DetailListColumnResizeHandle = ({
columnId,
disabled = false,
initialWidth,
onResize,
side,
@@ -1091,6 +1093,11 @@ const DetailListColumnResizeHandle = ({
}, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -1103,6 +1110,7 @@ const DetailListColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -4,6 +4,7 @@
flex-direction: column !important;
width: 100%;
height: 100%;
padding-block: var(--theme-spacing-xs);
padding-right: var(--theme-spacing-md);
outline: none;
border: none;
@@ -20,7 +20,8 @@ export const createColumnCellComponent = (
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data &&
prevProps.style === nextProps.style &&
prevProps.columns === nextProps.columns
prevProps.columns === nextProps.columns &&
prevProps.playlistId === nextProps.playlistId
);
},
);
@@ -8,49 +8,25 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatHrDateTime,
formatPartialIsoDateUTC,
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { TableColumn } from '/@/shared/types/types';
const getDateTooltipLabel = (utcString: string) => {
return (
<Stack gap="xs" justify="center">
<Text size="md" ta="center">
{formatHrDateTime(utcString)}
</Text>
<Text isMuted size="sm" ta="center">
{utcString}
</Text>
</Stack>
);
};
const DateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string' && row) {
return {
formattedDate: formatDateAbsolute(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
const formattedAbsolute = useMemo(
() => (typeof row === 'string' && row ? formatDateAbsolute(row) : null),
[row],
);
if (typeof row === 'string' && row) {
if (formattedAbsolute) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
<span>{formattedAbsolute}</span>
</TableColumnTextContainer>
);
}
@@ -79,44 +55,37 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
: null;
if (originalDate) {
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
return {
displayText,
tooltipLabel: getDateTooltipLabel(releaseDate),
};
const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
}
if (typeof releaseDate === 'string' && releaseDate) {
return {
displayText: formatDateAbsoluteUTC(releaseDate),
tooltipLabel: getDateTooltipLabel(releaseDate),
};
return formatPartialIsoDateUTC(releaseDate);
}
}
}
return null;
}, [props.type, rowItem]);
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string' && row) {
return {
formattedDate: formatDateAbsoluteUTC(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
const formattedIsoFallback = useMemo(
() => (typeof row === 'string' && row ? formatPartialIsoDateUTC(row) : null),
[row],
);
if (props.type === TableColumn.RELEASE_DATE) {
if (releaseDateContent) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}>
<span>{releaseDateContent.displayText}</span>
</Tooltip>
<span>{releaseDateContent}</span>
</TableColumnTextContainer>
);
}
if (formattedIsoFallback) {
return (
<TableColumnTextContainer {...props}>
<span>{formattedIsoFallback}</span>
</TableColumnTextContainer>
);
}
@@ -128,20 +97,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
return <ColumnSkeletonFixed {...props} />;
}
if (typeof row === 'string' && row) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonFixed {...props} />;
};
@@ -151,22 +106,15 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string') {
return {
formattedDate: formatDateRelative(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
const formattedRelative = useMemo(() => {
if (typeof row !== 'string') return null;
return formatDateRelative(row);
}, [row]);
if (typeof row === 'string') {
if (formattedRelative !== null) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
<span>{formattedRelative}</span>
</TableColumnTextContainer>
);
}
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { useMemo } from 'react';
import { Link } from 'react-router';
import styles from './title-column.module.css';
@@ -35,8 +36,12 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
@@ -80,8 +85,12 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
const song = rowItem as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
@@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => {
const item = rowItem as any;
const yearDisplay = useMemo(() => {
if (item && 'releaseYear' in item && item.releaseYear !== null) {
if (item && 'releaseYear' in item && item.releaseYear != null) {
const releaseYear = item.releaseYear;
const originalYear =
'originalYear' in item && item.originalYear !== null ? item.originalYear : null;
'originalYear' in item && item.originalYear > 0 ? item.originalYear : null;
if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -34,256 +34,268 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
}: UseItemDragDropStateProps): DragDropState<TElement> => {
const shouldEnableDrag = enableDrag && isDataRow && !!item;
const needsDropRegistration =
shouldEnableDrag &&
(itemType === LibraryItem.QUEUE_SONG || itemType === LibraryItem.PLAYLIST_SONG);
const {
isDraggedOver,
isDragging: isDraggingLocal,
ref: dragRef,
} = useDragDrop<TElement>({
drag: {
getId: () => {
if (!item || !isDataRow) {
return [];
}
drag: shouldEnableDrag
? {
getId: () => {
if (!item || !isDataRow) {
return [];
}
const draggedItems = getDraggedItems(item as any, internalState);
const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems.map((draggedItem) => draggedItem.id);
},
getItem: () => {
if (!item || !isDataRow) {
return [];
}
return draggedItems.map((draggedItem) => draggedItem.id);
},
getItem: () => {
if (!item || !isDataRow) {
return [];
}
const draggedItems = getDraggedItems(item as any, internalState);
const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems;
},
itemType,
onDragStart: () => {
if (!item || !isDataRow) {
return;
}
return draggedItems;
},
itemType,
onDragStart: () => {
if (!item || !isDataRow) {
return;
}
const draggedItems = getDraggedItems(item as any, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTargetMap[itemType] || DragTarget.GENERIC,
},
drop: {
canDrop: (args) => {
if (args.source.type === DragTarget.TABLE_COLUMN) {
return false;
}
const draggedItems = getDraggedItems(item as any, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTargetMap[itemType] || DragTarget.GENERIC,
}
: undefined,
drop: needsDropRegistration
? {
canDrop: (args) => {
if (args.source.type === DragTarget.TABLE_COLUMN) {
return false;
}
// Allow drops for QUEUE_SONG (queue reordering)
if (itemType === LibraryItem.QUEUE_SONG) {
return true;
}
// Allow drops for QUEUE_SONG (queue reordering)
if (itemType === LibraryItem.QUEUE_SONG) {
return true;
}
// Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle
if (
itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true
) {
return true;
}
// Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle
if (
itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true
) {
return true;
}
return false;
},
getData: () => {
return {
id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]],
itemType,
type: DragTargetMap[itemType] || DragTarget.GENERIC,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
if (args.self.type === DragTarget.QUEUE_SONG) {
const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string }
)._serverId;
return false;
},
getData: () => {
return {
id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]],
itemType,
type: DragTargetMap[itemType] || DragTarget.GENERIC,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
if (args.self.type === DragTarget.QUEUE_SONG) {
const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string }
)._serverId;
const sourceItemType = args.source.itemType as LibraryItem;
const sourceItemType = args.source.itemType as LibraryItem;
const droppedOnUniqueId = (
args.self.item?.[0] as unknown as { _uniqueId: string }
)._uniqueId;
const droppedOnUniqueId = (
args.self.item?.[0] as unknown as { _uniqueId: string }
)._uniqueId;
switch (args.source.type) {
case DragTarget.ALBUM: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ALBUM_ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
switch (args.source.type) {
case DragTarget.ALBUM: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ALBUM_ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if (
(item as unknown as Song)._itemType ===
LibraryItem.SONG
) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType ===
LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id);
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle songs: add directly to queue
if (songs.length > 0) {
playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
// Handle songs: add directly to queue
if (songs.length > 0) {
playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
case DragTarget.GENRE: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.PLAYLIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.QUEUE_SONG: {
const sourceItems = (args.source.item || []) as QueueSong[];
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom')
) {
playerContext.moveSelectedTo(
sourceItems,
args.edge,
droppedOnUniqueId,
);
}
break;
}
case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
default: {
break;
}
}
}
break;
}
case DragTarget.GENRE: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.PLAYLIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.QUEUE_SONG: {
const sourceItems = (args.source.item || []) as QueueSong[];
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom')
) {
playerContext.moveSelectedTo(
sourceItems,
args.edge,
droppedOnUniqueId,
);
}
break;
}
case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
default: {
break;
}
}
}
// Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle
if (
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true &&
playlistId
) {
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
// Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle
if (
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true &&
playlistId
) {
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
targetItem
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
}
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
targetItem
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
}
if (internalState) {
internalState.setDragging([]);
}
if (internalState) {
internalState.setDragging([]);
}
return;
},
},
return;
},
}
: undefined,
isEnabled: shouldEnableDrag,
});
@@ -1,3 +1,5 @@
import type { TableScrollShadowStore } from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
@@ -18,9 +20,7 @@ export const useTablePaneSync = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
scrollShadowStore,
}: {
enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined;
@@ -36,9 +36,7 @@ export const useTablePaneSync = ({
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
scrollShadowStore: TableScrollShadowStore;
}) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({
@@ -471,8 +469,10 @@ export const useTablePaneSync = ({
if (!row) {
const timeout = setTimeout(() => {
setShowLeftShadow(false);
setShowRightShadow(false);
scrollShadowStore.setSnapshot({
showLeftShadow: false,
showRightShadow: false,
});
}, 0);
return () => clearTimeout(timeout);
@@ -482,8 +482,10 @@ export const useTablePaneSync = ({
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
scrollShadowStore.setSnapshot({
showLeftShadow: pinnedLeftColumnCount > 0 && scrollLeft > 0,
showRightShadow: pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft,
});
}, 50);
checkScrollPosition();
@@ -494,13 +496,7 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition);
};
}, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
}, [pinnedLeftColumnCount, pinnedRightColumnCount, rowRef, scrollShadowStore]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
@@ -509,7 +505,7 @@ export const useTablePaneSync = ({
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
setShowTopShadow(false);
scrollShadowStore.setSnapshot({ showTopShadow: false });
}, 0);
return () => clearTimeout(timeout);
@@ -519,7 +515,7 @@ export const useTablePaneSync = ({
const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop;
setShowTopShadow(currentScrollTop > 0);
scrollShadowStore.setSnapshot({ showTopShadow: currentScrollTop > 0 });
}, 50);
checkScrollPosition();
@@ -530,5 +526,5 @@ export const useTablePaneSync = ({
checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, scrollShadowStore]);
};
@@ -366,6 +366,14 @@
opacity: 1;
}
.resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.header-container:hover .resize-handle.resize-handle-disabled {
cursor: not-allowed;
}
.resize-handle:hover {
opacity: 1;
}
@@ -19,7 +19,6 @@ import React, {
useRef,
useState,
} from 'react';
import { useParams } from 'react-router';
import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css';
@@ -58,6 +57,7 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
@@ -82,7 +82,6 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
}
const ItemTableListColumnBase = (props: ItemTableListColumn) => {
const { playlistId } = useParams() as { playlistId?: string };
const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn);
const isHeaderEnabled = !!props.enableHeader;
@@ -135,7 +134,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
item,
itemType: props.itemType,
playerContext: props.playerContext,
playlistId,
playlistId: props.playlistId,
});
const controls = props.controls;
@@ -195,6 +194,14 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
);
}
if (type === TableColumn.LAYOUT_FILL) {
return (
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
{null}
</TableColumnContainer>
);
}
if (itemType !== LibraryItem.FOLDER) {
switch (type) {
case TableColumn.ACTIONS:
@@ -362,6 +369,7 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding &&
prevProps.playlistId === nextProps.playlistId &&
prevItem === nextItem
);
});
@@ -708,6 +716,8 @@ export const TableColumnContainer = (
interface ColumnResizeHandleProps {
columnId: TableColumn;
columnIndex: number;
disabled?: boolean;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
@@ -715,6 +725,8 @@ interface ColumnResizeHandleProps {
const ColumnResizeHandle = ({
columnId,
columnIndex,
disabled = false,
initialWidth,
onResize,
side,
@@ -724,6 +736,17 @@ const ColumnResizeHandle = ({
const startWidthRef = useRef<number>(initialWidth);
const startXRef = useRef<number>(0);
const finalWidthRef = useRef<number>(initialWidth);
const columnResizeLive = useItemTableListColumnResizeLive();
const onResizeRef = useRef(onResize);
const columnResizeLiveRef = useRef(columnResizeLive);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
columnResizeLiveRef.current = columnResizeLive;
}, [columnResizeLive]);
// Update the ref when initialWidth changes (but not during drag)
useEffect(() => {
@@ -739,6 +762,7 @@ const ColumnResizeHandle = ({
const deltaX = event.clientX - startXRef.current;
const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);
finalWidthRef.current = newWidth;
columnResizeLiveRef.current?.scheduleColumnResizePreview(columnIndex, newWidth);
};
const handleMouseUp = () => {
@@ -747,7 +771,8 @@ const ColumnResizeHandle = ({
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
onResize(columnId, finalWidthRef.current);
onResizeRef.current(columnId, finalWidthRef.current);
columnResizeLiveRef.current?.clearColumnResizePreview();
};
document.addEventListener('mousemove', handleMouseMove);
@@ -756,10 +781,18 @@ const ColumnResizeHandle = ({
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
columnResizeLiveRef.current?.clearColumnResizePreview();
};
}, [isDragging, columnId, onResize]);
}, [isDragging, columnId, columnIndex]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
@@ -772,6 +805,7 @@ const ColumnResizeHandle = ({
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDisabled]: disabled,
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
@@ -803,7 +837,11 @@ export const TableColumnHeaderContainer = (
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(null);
useEffect(() => {
if (!containerRef.current || !props.enableColumnReorder) {
if (
!containerRef.current ||
!props.enableColumnReorder ||
props.type === TableColumn.LAYOUT_FILL
) {
return;
}
@@ -918,9 +956,11 @@ export const TableColumnHeaderContainer = (
>
{columnLabelMap[props.type]}
</Text>
{!columnConfig.autoSize && props.enableColumnResize && (
{props.enableColumnResize && (
<ColumnResizeHandle
columnId={props.type}
columnIndex={props.columnIndex}
disabled={!!columnConfig.autoSize}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
@@ -983,6 +1023,7 @@ export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', {
postProcess: 'upperCase',
}) as string,
[TableColumn.LAYOUT_FILL]: '',
[TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string,
[TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string,
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
@@ -1,31 +1,59 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import type { ReactElement } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
/**
* Stage A/B: Provide table-scoped config + external stores so churny values can update
* without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).
*/
export type ItemTableListConfig = {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[];
controls: ItemControls;
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: ItemTableListGroupHeader[];
internalState: ItemListStateActions;
itemType: LibraryItem;
playerContext: PlayerContext;
playlistId?: string;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
};
export type ItemTableListGroupHeader = {
itemCount: number;
render: (props: {
data: unknown[];
groupIndex: number;
index: number;
internalState: ItemListStateActions;
startDataIndex: number;
}) => ReactElement;
};
const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);
export const ItemTableListConfigProvider = ({
@@ -48,6 +76,69 @@ export const useItemTableListConfig = (): ItemTableListConfig | null => {
return useContext(ItemTableListConfigContext);
};
export type ItemTableListColumnResizeLiveContextValue = {
clearColumnResizePreview: () => void;
scheduleColumnResizePreview: (columnIndex: number, width: number) => void;
};
const ItemTableListColumnResizeLiveContext =
createContext<ItemTableListColumnResizeLiveContextValue | null>(null);
export const ItemTableListColumnResizeLiveProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: ItemTableListColumnResizeLiveContextValue;
}) => {
return (
<ItemTableListColumnResizeLiveContext.Provider value={value}>
{children}
</ItemTableListColumnResizeLiveContext.Provider>
);
};
export const useItemTableListColumnResizeLive =
(): ItemTableListColumnResizeLiveContextValue | null => {
return useContext(ItemTableListColumnResizeLiveContext);
};
export const useItemTableListColumnResizeLiveState = () => {
const [columnResizePreview, setColumnResizePreview] = useState<null | {
columnIndex: number;
width: number;
}>(null);
const previewRafRef = useRef<null | number>(null);
const pendingPreviewRef = useRef<null | { columnIndex: number; width: number }>(null);
const scheduleColumnResizePreview = useCallback((columnIndex: number, width: number) => {
pendingPreviewRef.current = { columnIndex, width };
if (previewRafRef.current !== null) return;
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
const pending = pendingPreviewRef.current;
if (pending) {
setColumnResizePreview(pending);
}
});
}, []);
const clearColumnResizePreview = useCallback(() => {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
pendingPreviewRef.current = null;
setColumnResizePreview(null);
}, []);
return {
clearColumnResizePreview,
columnResizePreview,
scheduleColumnResizePreview,
};
};
type ItemTableListStoreContextValue = {
activeRowStore: ActiveRowStore;
};
@@ -72,7 +72,7 @@
z-index: 15;
display: flex;
flex-direction: row;
overflow: hidden;
overflow: visible;
pointer-events: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
@@ -168,6 +168,7 @@
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: visible;
}
.no-scrollbar {
@@ -178,6 +179,10 @@
height: 100%;
}
.item-table-container :global(.os-scrollbar) {
z-index: 2;
}
.item-table-pinned-header-shadow {
position: absolute;
top: 100%;
@@ -14,11 +14,14 @@ import React, {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useParams } from 'react-router';
import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { appendLayoutFillColumn } from '/@/renderer/components/item-list/helpers/append-layout-fill-column';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
@@ -43,13 +46,20 @@ import { useTableRowModel } from '/@/renderer/components/item-list/item-table-li
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
ItemTableListConfigProvider,
ItemTableListStoreProvider,
useItemTableListColumnResizeLiveState,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import {
MemoizedCellRouter,
useColumnCellComponents,
} from '/@/renderer/components/item-list/item-table-list/memoized-cell-router';
import {
createTableScrollShadowStore,
type TableScrollShadowStore,
} from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store';
import {
ItemControls,
ItemListHandle,
@@ -101,30 +111,71 @@ export enum TableItemSize {
LARGE = 88,
}
const ItemTableScrollShadowTop = memo(function ItemTableScrollShadowTop({
enableHeader,
enableScrollShadow,
scrollShadowStore,
}: {
enableHeader: boolean;
enableScrollShadow: boolean;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showTopShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (!enableHeader || !enableScrollShadow || !showTopShadow) return null;
return <div className={styles.itemTableTopScrollShadow} />;
});
ItemTableScrollShadowTop.displayName = 'ItemTableScrollShadowTop';
const ItemTableScrollShadowLeft = memo(function ItemTableScrollShadowLeft({
enableScrollShadow,
pinnedLeftColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedLeftColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showLeftShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedLeftColumnCount <= 0 || !enableScrollShadow || !showLeftShadow) return null;
return <div className={styles.itemTableLeftScrollShadow} />;
});
ItemTableScrollShadowLeft.displayName = 'ItemTableScrollShadowLeft';
const ItemTableScrollShadowRight = memo(function ItemTableScrollShadowRight({
enableScrollShadow,
pinnedRightColumnCount,
scrollShadowStore,
}: {
enableScrollShadow: boolean;
pinnedRightColumnCount: number;
scrollShadowStore: TableScrollShadowStore;
}) {
const { showRightShadow } = useSyncExternalStore(
scrollShadowStore.subscribe,
scrollShadowStore.getSnapshot,
);
if (pinnedRightColumnCount <= 0 || !enableScrollShadow || !showRightShadow) return null;
return <div className={styles.itemTableRightScrollShadow} />;
});
ItemTableScrollShadowRight.displayName = 'ItemTableScrollShadowRight';
interface VirtualizedTableGridProps {
calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
controls: ItemControls;
data: unknown[];
dataWithGroups: (null | unknown)[];
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableScrollShadow: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
getItem?: (index: number) => undefined | unknown;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: TableGroupHeader[];
headerHeight: number;
internalState: ItemListStateActions;
itemType: LibraryItem;
mergedRowRef: React.Ref<HTMLDivElement>;
onRangeChanged?: ItemTableListProps['onRangeChanged'];
parsedColumns: ReturnType<typeof parseTableColumns>;
@@ -134,13 +185,8 @@ interface VirtualizedTableGridProps {
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
scrollShadowStore: TableScrollShadowStore;
tableConfig: ItemTableListConfig;
totalColumnCount: number;
totalRowCount: number;
}
@@ -148,27 +194,11 @@ interface VirtualizedTableGridProps {
const VirtualizedTableGrid = ({
calculatedColumnWidths,
CellComponent,
cellPadding,
controls,
data,
dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableScrollShadow,
enableSelection,
enableVerticalBorders,
getItem,
getRowHeight,
groups,
headerHeight,
internalState,
itemType,
mergedRowRef,
onRangeChanged,
parsedColumns,
@@ -178,16 +208,12 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef,
pinnedRowCount,
pinnedRowRef,
playerContext,
showLeftShadow,
showRightShadow,
showTopShadow,
size,
startRowIndex,
tableId,
scrollShadowStore,
tableConfig,
totalColumnCount,
totalRowCount,
}: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({
@@ -345,35 +371,7 @@ const VirtualizedTableGrid = ({
],
);
const stableConfigProps = useMemo(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableHeader,
getRowHeight,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
internalState,
itemType,
playerContext,
size,
tableId,
}),
[
cellPadding,
parsedColumns,
controls,
enableHeader,
getRowHeight,
internalState,
itemType,
playerContext,
size,
tableId,
],
);
const dynamicDataProps = useMemo(
const gridOnlyProps = useMemo(
() => ({
calculatedColumnWidths,
data: dataWithGroups,
@@ -381,11 +379,11 @@ const VirtualizedTableGrid = ({
getGroupRenderData,
getRowItem,
groupHeaderInfoByRowIndex,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
startRowIndex,
}),
[
calculatedColumnWidths,
@@ -394,50 +392,68 @@ const VirtualizedTableGrid = ({
getAdjustedRowIndex,
getGroupRenderData,
groupHeaderInfoByRowIndex,
parsedColumns,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
startRowIndex,
],
);
const featureFlags = useMemo(
() => ({
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
}),
[
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
],
);
const itemProps: TableItemProps = useMemo(
() => ({
...stableConfigProps,
...dynamicDataProps,
...featureFlags,
cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns,
controls: tableConfig.controls,
enableAlternateRowColors: tableConfig.enableAlternateRowColors,
enableColumnReorder: tableConfig.enableColumnReorder,
enableColumnResize: tableConfig.enableColumnResize,
enableDrag: tableConfig.enableDrag,
enableExpansion: tableConfig.enableExpansion,
enableHeader: tableConfig.enableHeader,
enableHorizontalBorders: tableConfig.enableHorizontalBorders,
enableRowHoverHighlight: tableConfig.enableRowHoverHighlight,
enableSelection: tableConfig.enableSelection,
enableVerticalBorders: tableConfig.enableVerticalBorders,
getRowHeight: tableConfig.getRowHeight,
groups: tableConfig.groups,
internalState: tableConfig.internalState,
itemType: tableConfig.itemType,
playerContext: tableConfig.playerContext,
playlistId: tableConfig.playlistId,
size: tableConfig.size,
startRowIndex: tableConfig.startRowIndex,
tableId: tableConfig.tableId,
...gridOnlyProps,
}),
[stableConfigProps, dynamicDataProps, featureFlags],
[gridOnlyProps, tableConfig],
);
const pinnedLeftGridMinWidthPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedLeftColumnCount; i++) {
sum += calculatedColumnWidths[i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount]);
const pinnedRightGridMinWidthPx = useMemo(() => {
let sum = 0;
const start = pinnedLeftColumnCount + totalColumnCount;
for (let i = 0; i < pinnedRightColumnCount; i++) {
sum += calculatedColumnWidths[start + i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount]);
const pinnedRowsMinHeightPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedRowCount; i++) {
sum += getRowHeight(i, itemProps);
}
return sum;
}, [getRowHeight, itemProps, pinnedRowCount]);
const PinnedRowCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return (
@@ -447,16 +463,14 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],
[pinnedLeftColumnCount, CellComponent],
);
const PinnedColumnCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],
[pinnedRowCount, CellComponent],
);
const PinnedRightColumnCell = useCallback(
@@ -469,15 +483,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
pinnedRowCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
);
const PinnedRightIntersectionCell = useCallback(
@@ -489,14 +495,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, totalColumnCount, CellComponent],
);
const RowCell = useCallback(
@@ -509,14 +508,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
pinnedRowCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
);
const handleOnCellsRendered = useCallback(
@@ -541,10 +533,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
(a, _, i) => a + columnWidth(i),
0,
)}px`,
minWidth: `${pinnedLeftGridMinWidthPx}px`,
} as React.CSSProperties
}
>
@@ -554,11 +543,8 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(
(a, _, i) => a + getRowHeight(i, itemProps),
0,
)}px`,
overflow: 'hidden',
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible',
}}
>
<Grid
@@ -572,9 +558,11 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
{!!pinnedLeftColumnCount && (
<div
className={styles.itemTablePinnedColumnsContainer}
@@ -611,10 +599,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
} as React.CSSProperties
}
@@ -627,14 +612,16 @@ const VirtualizedTableGrid = ({
columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount);
}}
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}
rowCount={pinnedRowCount}
rowHeight={getRowHeight}
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
@@ -646,12 +633,16 @@ const VirtualizedTableGrid = ({
rowCount={totalRowCount}
rowHeight={rowHeightMemoized}
/>
{pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
{pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
<div className={styles.itemTableRightScrollShadow} />
)}
<ItemTableScrollShadowLeft
enableScrollShadow={enableScrollShadow}
pinnedLeftColumnCount={pinnedLeftColumnCount}
scrollShadowStore={scrollShadowStore}
/>
<ItemTableScrollShadowRight
enableScrollShadow={enableScrollShadow}
pinnedRightColumnCount={pinnedRightColumnCount}
scrollShadowStore={scrollShadowStore}
/>
</div>
</div>
{!!pinnedRightColumnCount && (
@@ -660,14 +651,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from(
{ length: pinnedRightColumnCount },
() => 0,
).reduce(
(a, _, i) =>
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
0,
)}px`,
minWidth: `${pinnedRightGridMinWidthPx}px`,
} as React.CSSProperties
}
>
@@ -677,11 +661,8 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
overflow: 'hidden',
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'visible',
}}
>
<Grid
@@ -699,9 +680,11 @@ const VirtualizedTableGrid = ({
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<ItemTableScrollShadowTop
enableHeader={!!enableHeader}
enableScrollShadow={enableScrollShadow}
scrollShadowStore={scrollShadowStore}
/>
<div
className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef}
@@ -739,27 +722,12 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.calculatedColumnWidths,
nextProps.calculatedColumnWidths,
) &&
prevProps.cellPadding === nextProps.cellPadding &&
prevProps.controls === nextProps.controls &&
prevProps.tableConfig === nextProps.tableConfig &&
prevProps.data === nextProps.data &&
prevProps.dataWithGroups === nextProps.dataWithGroups &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableDrag === nextProps.enableDrag &&
prevProps.enableExpansion === nextProps.enableExpansion &&
prevProps.enableHeader === nextProps.enableHeader &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.getItem === nextProps.getItem &&
prevProps.getRowHeight === nextProps.getRowHeight &&
prevProps.groups === nextProps.groups &&
prevProps.headerHeight === nextProps.headerHeight &&
prevProps.internalState === nextProps.internalState &&
prevProps.itemType === nextProps.itemType &&
prevProps.mergedRowRef === nextProps.mergedRowRef &&
prevProps.onRangeChanged === nextProps.onRangeChanged &&
prevProps.parsedColumns === nextProps.parsedColumns &&
@@ -769,13 +737,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
prevProps.playerContext === nextProps.playerContext &&
prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.size === nextProps.size &&
prevProps.startRowIndex === nextProps.startRowIndex &&
prevProps.tableId === nextProps.tableId &&
prevProps.scrollShadowStore === nextProps.scrollShadowStore &&
prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent
@@ -828,6 +790,7 @@ export interface TableItemProps {
pinnedRightColumnCount?: number;
pinnedRightColumnWidths?: number[];
playerContext: PlayerContext;
playlistId?: string;
size?: ItemTableListProps['size'];
startRowIndex?: number;
tableId: string;
@@ -1008,7 +971,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
{parsedColumns
@@ -1092,7 +1055,7 @@ const ItemTableListStickyUI = memo(
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
overflow: 'visible',
}}
>
{parsedColumns
@@ -1309,12 +1272,18 @@ const BaseItemTableList = ({
size = 'default',
startRowIndex,
}: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId();
const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
const [totalContainerWidth, setTotalContainerWidth] = useState(0);
const columnsForLayout = useMemo(
() => appendLayoutFillColumn(columns, autoFitColumns),
[autoFitColumns, columns],
);
const {
calculatedColumnWidths,
parsedColumns,
@@ -1324,9 +1293,33 @@ const BaseItemTableList = ({
} = useTableColumnModel({
autoFitColumns,
centerContainerWidth,
columns,
columns: columnsForLayout,
totalContainerWidth,
});
const { clearColumnResizePreview, columnResizePreview, scheduleColumnResizePreview } =
useItemTableListColumnResizeLiveState();
const columnResizeLiveValue = useMemo(
() => ({
clearColumnResizePreview,
scheduleColumnResizePreview,
}),
[clearColumnResizePreview, scheduleColumnResizePreview],
);
const displayColumnWidths = useMemo(() => {
if (!columnResizePreview) {
return calculatedColumnWidths;
}
const next = calculatedColumnWidths.slice();
const { columnIndex, width } = columnResizePreview;
if (columnIndex >= 0 && columnIndex < next.length) {
next[columnIndex] = width;
}
return next;
}, [calculatedColumnWidths, columnResizePreview]);
const playerContext = usePlayer();
const {
@@ -1362,9 +1355,7 @@ const BaseItemTableList = ({
const pinnedRightColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const [showTopShadow, setShowTopShadow] = useState(false);
const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []);
const handleRef = useRef<ItemListHandle | null>(null);
const { focused, ref: focusRef } = useFocusWithin();
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -1422,9 +1413,7 @@ const BaseItemTableList = ({
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
scrollShadowStore,
});
const getRowHeight = useCallback(
@@ -1548,7 +1537,7 @@ const BaseItemTableList = ({
// Create itemProps for sticky header
const stickyHeaderItemProps: TableItemProps = useMemo(
() => ({
calculatedColumnWidths,
calculatedColumnWidths: displayColumnWidths,
cellPadding,
columns: parsedColumns,
controls,
@@ -1568,17 +1557,18 @@ const BaseItemTableList = ({
internalState,
itemType,
pinnedLeftColumnCount,
pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount),
pinnedRightColumnCount,
pinnedRightColumnWidths: calculatedColumnWidths.slice(
pinnedRightColumnWidths: displayColumnWidths.slice(
pinnedLeftColumnCount + totalColumnCount,
),
playerContext,
playlistId: routePlaylistId,
size,
tableId,
}),
[
calculatedColumnWidths,
displayColumnWidths,
cellPadding,
controls,
parsedColumns,
@@ -1599,6 +1589,7 @@ const BaseItemTableList = ({
pinnedLeftColumnCount,
pinnedRightColumnCount,
playerContext,
routePlaylistId,
size,
tableId,
totalColumnCount,
@@ -1612,17 +1603,27 @@ const BaseItemTableList = ({
itemType,
});
const tableConfigValue = useMemo(
const tableConfigValue = useMemo<ItemTableListConfig>(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableAlternateRowColors,
enableColumnReorder: !!onColumnReordered,
enableColumnResize: !!onColumnResized,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
playerContext,
playlistId: routePlaylistId,
size,
startRowIndex,
tableId,
@@ -1631,12 +1632,22 @@ const BaseItemTableList = ({
cellPadding,
parsedColumns,
controls,
enableAlternateRowColors,
onColumnReordered,
onColumnResized,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
playerContext,
routePlaylistId,
size,
startRowIndex,
tableId,
@@ -1662,92 +1673,81 @@ const BaseItemTableList = ({
};
}, [CellComponent, columnCellComponents]);
const tableMotion = (
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={displayColumnWidths}
CellComponent={optimizedCellComponent}
data={data}
dataWithGroups={dataWithGroups}
enableScrollShadow={enableScrollShadow}
getItem={getItem}
headerHeight={headerHeight}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
scrollShadowStore={scrollShadowStore}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
);
return (
<ItemTableListStoreProvider activeRowId={activeRowId}>
<ItemTableListConfigProvider value={tableConfigValue}>
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
const element = e.currentTarget as HTMLDivElement;
// Focus without scrolling into view
if (element.focus) {
element.focus({ preventScroll: true });
}
}}
ref={mergedContainerRef}
tabIndex={0}
{...animationProps.fadeIn}
transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }}
>
<ItemTableListStickyUI
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
containerRef={containerRef}
data={data}
enableHeader={!!enableHeader}
enableStickyGroupRows={!!enableStickyGroupRows}
enableStickyHeader={!!enableStickyHeader}
getRowHeightWrapper={getRowHeightWrapper}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef}
rowHeight={rowHeight}
rowRef={rowRef}
size={size}
stickyHeaderItemProps={stickyHeaderItemProps}
totalColumnCount={totalColumnCount}
/>
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
cellPadding={cellPadding}
controls={controls}
data={data}
dataWithGroups={dataWithGroups}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnReorder={!!onColumnReordered}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableScrollShadow={enableScrollShadow}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getItem={getItem}
getRowHeight={getRowHeight}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
itemType={itemType}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
playerContext={playerContext}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
size={size}
startRowIndex={startRowIndex}
tableId={tableId}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
</motion.div>
{onColumnResized ? (
<ItemTableListColumnResizeLiveProvider value={columnResizeLiveValue}>
{tableMotion}
</ItemTableListColumnResizeLiveProvider>
) : (
tableMotion
)}
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
);
@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import React, { useMemo } from 'react';
import { CellComponentProps } from 'react-window-v2';
import { createColumnCellComponents } from './cell-component-factory';
@@ -24,24 +24,7 @@ const MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {
return <ItemTableListColumn {...props} />;
};
export const MemoizedCellRouter = memo(MemoizedCellRouterBase, (prevProps, nextProps) => {
return (
prevProps.rowIndex === nextProps.rowIndex &&
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data &&
prevProps.columns === nextProps.columns &&
prevProps.columnCellComponents === nextProps.columnCellComponents &&
prevProps.size === nextProps.size &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding
);
});
export const MemoizedCellRouter = MemoizedCellRouterBase;
export const useColumnCellComponents = (
columns: TableColumn[],
@@ -0,0 +1,36 @@
export interface TableScrollShadowSnapshot {
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
}
export type TableScrollShadowStore = ReturnType<typeof createTableScrollShadowStore>;
export function createTableScrollShadowStore() {
let snapshot: TableScrollShadowSnapshot = {
showLeftShadow: false,
showRightShadow: false,
showTopShadow: false,
};
const listeners = new Set<() => void>();
return {
getSnapshot: (): TableScrollShadowSnapshot => snapshot,
setSnapshot: (patch: Partial<TableScrollShadowSnapshot>) => {
const next: TableScrollShadowSnapshot = { ...snapshot, ...patch };
if (
next.showLeftShadow === snapshot.showLeftShadow &&
next.showRightShadow === snapshot.showRightShadow &&
next.showTopShadow === snapshot.showTopShadow
) {
return;
}
snapshot = next;
listeners.forEach((l) => l());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -97,7 +97,7 @@
justify-content: center;
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
padding: 0 var(--theme-spacing-xs);
}
.title-wrapper.hidden {
@@ -11,8 +11,10 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Flex, FlexProps } from '/@/shared/components/flex/flex';
import { Platform } from '/@/shared/types/types';
export interface PageHeaderProps
extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {
export interface PageHeaderProps extends Omit<
FlexProps,
'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'
> {
animated?: boolean;
backgroundColor?: string;
children?: ReactNode;
@@ -94,6 +94,7 @@ interface AlbumMetadataTagsProps {
}
const MOOD_TAG = 'mood';
const GROUPING_TAG = 'grouping';
const RELEASE_COUNTRY_TAG = 'releasecountry';
const RELEASE_STATUS_TAG = 'releasestatus';
@@ -155,6 +156,30 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
}));
}, [album]);
const groupingItems = useMemo(() => {
if (!album) return [];
return (
album.tags?.[GROUPING_TAG]?.map((tag) => {
if (album._serverType !== ServerType.NAVIDROME) {
return { id: tag, label: tag, url: null };
}
const searchParams = new URLSearchParams();
const paramsWithCustom = setJsonSearchParam(
searchParams,
FILTER_KEYS.ALBUM._CUSTOM,
{ grouping: [tag] },
);
return {
id: tag,
label: tag,
url: `${AppRoute.LIBRARY_ALBUMS}?${paramsWithCustom.toString()}`,
};
}) ?? []
);
}, [album]);
const recordLabels = useMemo(() => {
if (!album?.recordLabels || album.recordLabels.length === 0) return [];
@@ -221,6 +246,29 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
items={moodTagItems}
title={t('common.mood', { postProcess: 'sentenceCase' })}
/>
{groupingItems.length > 0 && (
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('common.grouping', { postProcess: 'sentenceCase' })}
</Text>
<div className={styles['pill-group-wrapper']}>
<Pill.Group>
{groupingItems.map((item) =>
item.url ? (
<PillLink key={`grouping-${item.id}`} size="md" to={item.url}>
{item.label}
</PillLink>
) : (
<Pill key={`grouping-${item.id}`} size="md">
{item.label}
</Pill>
),
)}
</Pill.Group>
</div>
</Stack>
)}
</>
);
};
@@ -296,25 +344,60 @@ interface AlbumMetadataExternalLinksProps {
albumName?: string;
externalLinks: boolean;
lastFM: boolean;
listenBrainz: boolean;
mbzId?: null | string;
mbzReleaseGroupId?: null | string;
musicBrainz: boolean;
nativeSpotify: boolean;
qobuz: boolean;
spotify: boolean;
}
const getListenBrainzUrl = (
mbzReleaseGroupId: null | string,
albumArtist?: string,
albumName?: string,
) => {
if (mbzReleaseGroupId) {
return `https://listenbrainz.org/album/${mbzReleaseGroupId}`;
}
if (albumArtist || albumName) {
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
}
return null;
};
const getQobuzUrl = (albumArtist?: string, albumName?: string) => {
if (albumArtist || albumName) {
return `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`;
}
return null;
};
const AlbumMetadataExternalLinks = ({
albumArtist,
albumName,
externalLinks,
lastFM,
listenBrainz,
mbzId,
mbzReleaseGroupId,
musicBrainz,
nativeSpotify,
qobuz,
spotify,
}: AlbumMetadataExternalLinksProps) => {
const { t } = useTranslation();
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
const listenBrainzUrl = getListenBrainzUrl(mbzReleaseGroupId || null, albumArtist, albumName);
const qobuzUrl = getQobuzUrl(albumArtist, albumName);
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
return null;
}
return (
<Stack gap="xs">
@@ -323,7 +406,7 @@ const AlbumMetadataExternalLinks = ({
postProcess: 'sentenceCase',
})}
</Text>
<Group className={styles.externalLinksGroup} gap="sm">
<Group className={styles.externalLinksGroup} gap="xs">
{lastFM && (
<ActionIcon
component="a"
@@ -332,8 +415,7 @@ const AlbumMetadataExternalLinks = ({
)}/${encodeURIComponent(albumName || '')}`}
icon="brandLastfm"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
radius="md"
rel="noopener noreferrer"
@@ -350,8 +432,7 @@ const AlbumMetadataExternalLinks = ({
href={`https://musicbrainz.org/release/${mbzId}`}
icon="brandMusicBrainz"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
radius="md"
rel="noopener noreferrer"
@@ -362,6 +443,40 @@ const AlbumMetadataExternalLinks = ({
variant="subtle"
/>
) : null}
{listenBrainz && listenBrainzUrl && (
<ActionIcon
component="a"
href={listenBrainzUrl}
icon="brandListenBrainz"
iconProps={{
size: '2xl',
}}
radius="md"
rel="noopener noreferrer"
target="_blank"
tooltip={{
label: t('action.openIn.listenbrainz'),
}}
variant="subtle"
/>
)}
{qobuz && qobuzUrl && (
<ActionIcon
component="a"
href={qobuzUrl}
icon="brandQobuz"
iconProps={{
size: '2xl',
}}
radius="md"
rel="noopener noreferrer"
target="_blank"
tooltip={{
label: t('action.openIn.qobuz'),
}}
variant="subtle"
/>
)}
{spotify && (
<ActionIcon
component="a"
@@ -372,8 +487,7 @@ const AlbumMetadataExternalLinks = ({
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
radius="md"
rel="noopener noreferrer"
@@ -396,7 +510,8 @@ export const AlbumDetailContent = () => {
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
);
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
useExternalLinks();
const comment = detailQuery?.data?.comment;
@@ -427,9 +542,12 @@ export const AlbumDetailContent = () => {
albumName={detailQuery?.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
listenBrainz={listenBrainz}
mbzId={mbzId || undefined}
mbzReleaseGroupId={detailQuery?.data?.mbzReleaseGroupId}
musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
qobuz={qobuz}
spotify={spotify}
/>
</div>
@@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils';
import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator';
@@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate;
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;
const originalYearDifferentFromRelease =
album.originalYear > 0 &&
album.releaseYear != null &&
album.originalYear !== album.releaseYear;
const playCount = album?.playCount;
@@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (originalDifferentFromRelease) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
value: `${formatPartialIsoDateUTC(album.originalDate)}`,
});
}
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
});
}
} else if (album.originalYear) {
} else if (album.originalYear > 0) {
if (originalYearDifferentFromRelease) {
items.push({
id: 'originalYear',
@@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
});
} else if (releaseYear) {
} else if (releaseYear != null && releaseYear > 0) {
items.push({
id: 'releaseYear',
value: `${releaseYearPrefix} ${releaseYear}`,
});
}
} else if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${formatPartialIsoDateUTC(releaseDate)}`,
});
} else if (releaseYear != null && releaseYear > 0) {
items.push({
id: 'releaseYear',
value: `${releaseYear}`,
});
}
items.push(
@@ -54,15 +54,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
return Boolean(
isFilterValueSet(query[FILTER_KEYS.ALBUM._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||
query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||
query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||
query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||
query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
isFilterValueSet(query[FILTER_KEYS.ALBUM.ARTIST_IDS]) ||
query[FILTER_KEYS.ALBUM.COMPILATION] !== undefined ||
query[FILTER_KEYS.ALBUM.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.GENRE_ID]) ||
query[FILTER_KEYS.ALBUM.HAS_RATING] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MAX_YEAR]) ||
isFilterValueSet(query[FILTER_KEYS.ALBUM.MIN_YEAR]) ||
query[FILTER_KEYS.ALBUM.RECENTLY_PLAYED] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SHARED.SEARCH_TERM]),
);
}, [albumFilters.query]);
@@ -13,6 +13,7 @@ import {
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -74,8 +75,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setGenreId = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -83,8 +86,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setAlbumArtist = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -92,8 +100,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMinYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -101,8 +111,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setMaxYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -110,8 +122,10 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setFavorite = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
replace: true,
});
});
},
[setSearchParams],
@@ -119,8 +133,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setCompilation = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -128,8 +147,13 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), {
replace: true,
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value),
{
replace: true,
},
);
});
},
[setSearchParams],
@@ -137,65 +161,71 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => {
const setRecentlyPlayed = useCallback(
(value: boolean | null) => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
{
replace: true,
},
);
runInUrlTransition(() => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
{
replace: true,
},
);
});
},
[setSearchParams],
);
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
runInUrlTransition(() => {
setSearchParams(
(prev) => {
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const newCustom = {
...(previousValue ? JSON.parse(previousValue) : {}),
...value,
};
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
const filteredNewCustom = Object.fromEntries(
Object.entries(newCustom).filter(
([, value]) => value !== null && value !== undefined,
),
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
return prev;
},
{
replace: true,
},
);
});
},
[setSearchParams],
);
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
},
new Set([FILTER_KEYS.ALBUM._CUSTOM]),
),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.ALBUM._CUSTOM]: null,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: null,
[FILTER_KEYS.ALBUM.COMPILATION]: null,
[FILTER_KEYS.ALBUM.FAVORITE]: null,
[FILTER_KEYS.ALBUM.GENRE_ID]: null,
[FILTER_KEYS.ALBUM.HAS_RATING]: null,
[FILTER_KEYS.ALBUM.MAX_YEAR]: null,
[FILTER_KEYS.ALBUM.MIN_YEAR]: null,
[FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null,
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
},
new Set([FILTER_KEYS.ALBUM._CUSTOM]),
),
{ replace: true },
);
});
}, [setSearchParams]);
const query = useMemo(
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useLocation, useParams } from 'react-router';
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';
@@ -16,23 +16,21 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { useAlbumBackground, useCurrentServerId } from '/@/renderer/store';
import { LibraryItem } from '/@/shared/types/domain-types';
const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)';
const AlbumDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const serverId = useCurrentServerId();
const location = useLocation();
const detailQuery = useQuery({
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
placeholderData: location.state?.item,
const detailQuery = useSuspenseQuery({
...albumQueries.detail({ query: { id: albumId }, serverId }),
});
const imageUrl =
@@ -42,25 +40,21 @@ const AlbumDetailRoute = () => {
type: 'itemCard',
}) || '';
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
const { background: backgroundColor } = useFastAverageColor({
id: albumId,
src: imageUrl,
srcLoaded: true,
});
const background = backgroundColor;
const background = backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK;
const showBlurredImage = albumBackground;
if (isColorLoading) {
return <Spinner container />;
}
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
pageHeaderProps={{
backgroundColor: backgroundColor || undefined,
backgroundColor: backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
@@ -68,9 +62,7 @@ const AlbumDetailRoute = () => {
itemType={LibraryItem.ALBUM}
variant="default"
/>
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>{detailQuery.data.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, Link, useParams } from 'react-router';
@@ -39,7 +39,7 @@ const DummyAlbumDetailRoute = () => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
const detailQuery = useQuery({
const detailQuery = useSuspenseQuery({
queryFn: ({ signal }) => {
return api.controller.getSongDetail({
apiClientProps: { serverId: server?.id || '', signal },
@@ -52,7 +52,7 @@ const DummyAlbumDetailRoute = () => {
const { background, colorId } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
srcLoaded: Boolean(detailQuery.data?.imageUrl),
});
const { addToQueueByFetch } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior();
@@ -888,26 +888,54 @@ interface AlbumArtistMetadataExternalLinksProps {
artistName?: string;
externalLinks: boolean;
lastFM: boolean;
listenBrainz: boolean;
mbzId?: null | string;
musicBrainz: boolean;
nativeSpotify: boolean;
order?: number;
qobuz: boolean;
spotify: boolean;
}
const getListenBrainzUrl = (mbzId: null | string, artistName?: string) => {
if (mbzId) {
return `https://listenbrainz.org/artist/${mbzId}`;
}
if (artistName) {
return `https://listenbrainz.org/search/?search_term=${encodeURIComponent(artistName)}`;
}
return null;
};
const getQobuzUrl = (artistName?: string) => {
if (artistName) {
return `https://www.qobuz.com/us-en/search/artists/${encodeURIComponent(artistName)}`;
}
return null;
};
const AlbumArtistMetadataExternalLinks = ({
artistName,
externalLinks,
lastFM,
listenBrainz,
mbzId,
musicBrainz,
nativeSpotify,
order,
qobuz,
spotify,
}: AlbumArtistMetadataExternalLinksProps) => {
const { t } = useTranslation();
const listenBrainzUrl = getListenBrainzUrl(mbzId || null, artistName);
const qobuzUrl = getQobuzUrl(artistName);
if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) {
return null;
}
return (
<Grid.Col order={order} span={12}>
@@ -917,15 +945,14 @@ const AlbumArtistMetadataExternalLinks = ({
postProcess: 'sentenceCase',
})}
</Text>
<Group gap="sm">
<Group gap="xs">
{lastFM && (
<ActionIcon
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(artistName || '')}`}
icon="brandLastfm"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
rel="noopener noreferrer"
target="_blank"
@@ -941,8 +968,7 @@ const AlbumArtistMetadataExternalLinks = ({
href={`https://musicbrainz.org/artist/${mbzId}`}
icon="brandMusicBrainz"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
rel="noopener noreferrer"
target="_blank"
@@ -952,6 +978,38 @@ const AlbumArtistMetadataExternalLinks = ({
variant="subtle"
/>
) : null}
{listenBrainz && listenBrainzUrl && (
<ActionIcon
component="a"
href={listenBrainzUrl}
icon="brandListenBrainz"
iconProps={{
size: '2xl',
}}
rel="noopener noreferrer"
target="_blank"
tooltip={{
label: t('action.openIn.listenbrainz'),
}}
variant="subtle"
/>
)}
{qobuz && qobuzUrl && (
<ActionIcon
component="a"
href={qobuzUrl}
icon="brandQobuz"
iconProps={{
size: '2xl',
}}
rel="noopener noreferrer"
target="_blank"
tooltip={{
label: t('action.openIn.qobuz'),
}}
variant="subtle"
/>
)}
{spotify && (
<ActionIcon
component="a"
@@ -962,8 +1020,7 @@ const AlbumArtistMetadataExternalLinks = ({
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
size: '2xl',
}}
rel="noopener noreferrer"
target={nativeSpotify ? undefined : '_blank'}
@@ -1075,7 +1132,8 @@ export const AlbumArtistDetailContent = ({
}: AlbumArtistDetailContentProps) => {
const artistItems = useArtistItems();
const artistRadioCount = useArtistRadioCount();
const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } =
useExternalLinks();
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;
@@ -1161,18 +1219,21 @@ export const AlbumArtistDetailContent = ({
genres={detailQuery.data?.genres}
order={genresOrder}
/>
{externalLinks && (lastFM || musicBrainz || spotify) && (
<AlbumArtistMetadataExternalLinks
artistName={detailQuery.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
mbzId={mbzId}
musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
order={externalLinksOrder}
spotify={spotify}
/>
)}
{externalLinks &&
(lastFM || listenBrainz || musicBrainz || qobuz || spotify) && (
<AlbumArtistMetadataExternalLinks
artistName={detailQuery.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
listenBrainz={listenBrainz}
mbzId={mbzId}
musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
order={externalLinksOrder}
qobuz={qobuz}
spotify={spotify}
/>
)}
{enabledItem.biography && (
<AlbumArtistMetadataBiography
artistName={detailQuery.data?.name}
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback, useMemo } from 'react';
import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -8,6 +8,8 @@ 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 { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils';
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { hasFeature, SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';
import {
AlbumArtistDetailResponse,
AlbumListResponse,
LibraryItem,
ServerType,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
}
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deleteArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
({ albumsQuery }, ref) => {
const { albumArtistId, artistId } = useParams() as {
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy;
@@ -167,38 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
[detailQuery.data],
);
const imageUrl = useItemImageUrl({
const headerImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
type: 'header',
});
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => {
return detailQuery.data?.imageUrl || imageUrl;
}, [detailQuery.data?.imageUrl, imageUrl]);
const canUploadArtistImage =
hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
Boolean(detailQuery.data?._serverId);
const alternateImageUrl = artistInfoQuery.data?.imageUrl;
const handleArtistImageUpload = useCallback(
async (file: File) => {
const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return (
<LibraryHeader
imageUrl={alternateImageUrl || selectedImageUrl}
imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{
imageId: detailQuery.data?.imageId,
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,
imageUrl: detailQuery.data?.imageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST,
}}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref}
title={detailQuery.data?.name || ''}
>
@@ -16,8 +16,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListInfiniteGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteGrid = ({
gap = 'md',
@@ -17,8 +17,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListInfiniteTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListInfiniteTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListInfiniteTable = ({
autoFitColumns = false,
@@ -18,8 +18,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedGridProps
extends ItemListGridComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListPaginatedGridProps extends ItemListGridComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedGrid = ({
gap = 'md',
@@ -19,8 +19,7 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistListPaginatedTableProps
extends ItemListTableComponentProps<AlbumArtistListQuery> {}
interface AlbumArtistListPaginatedTableProps extends ItemListTableComponentProps<AlbumArtistListQuery> {}
export const AlbumArtistListPaginatedTable = ({
autoFitColumns = false,
@@ -6,6 +6,7 @@ import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-f
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -19,13 +20,15 @@ export const useAlbumArtistListFilters = () => {
const [, setSearchParams] = useSearchParams();
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(prev, {
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
}),
{ replace: true },
);
runInUrlTransition(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(prev, {
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
}),
{ replace: true },
);
});
}, [setSearchParams]);
const query = {
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
export const useDeleteArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deleteArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
export const useUploadArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQueries } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
@@ -35,20 +35,18 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
const server = useCurrentServer();
const pageKey = LibraryItem.SONG;
const detailQuery = useQuery(
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
);
const favoriteSongsQuery = useQuery(
artistsQueries.favoriteSongs({
options: { enabled: !!detailQuery?.data?.name },
query: { artistId: routeId },
serverId: server?.id,
}),
);
const [detailQuery, favoriteSongsQuery] = useSuspenseQueries({
queries: [
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
artistsQueries.favoriteSongs({
query: { artistId: routeId },
serverId: server?.id,
}),
],
});
const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [],
@@ -168,7 +166,7 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
);
};
const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
const AlbumArtistDetailFavoriteSongsListRouteWithBoundary = () => {
return (
<PageErrorBoundary>
<AlbumArtistDetailFavoriteSongsListRoute />
@@ -176,4 +174,4 @@ const AlbumArtistDetailTopSongsListRouteWithBoundary = () => {
);
};
export default AlbumArtistDetailTopSongsListRouteWithBoundary;
export default AlbumArtistDetailFavoriteSongsListRouteWithBoundary;
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
@@ -34,16 +34,15 @@ const AlbumArtistDetailTopSongsListRoute = () => {
key: 'album-artist-top-songs-query-type',
});
const detailQuery = useQuery(
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
);
const topSongsQuery = useQuery(
const topSongsQuery = useSuspenseQuery(
artistsQueries.topSongs({
options: { enabled: !!detailQuery?.data?.name },
query: {
artist: detailQuery?.data?.name || '',
artistId: routeId,
@@ -347,7 +347,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
innerProps: {
...modalProps,
},
modalKey: 'addToPlaylist',
modal: 'addToPlaylist',
size: 'lg',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
@@ -36,7 +36,7 @@ export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
itemIds: ids,
resourceType,
},
modalKey: 'shareItem',
modal: 'shareItem',
title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }),
});
}, [ids, resourceType, t]);

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