Compare commits

..

106 Commits

Author SHA1 Message Date
Hosted Weblate 324936e0c8 Translated using Weblate
Currently translated at 100.0% (1208 of 1208 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% (1208 of 1208 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: York <goog10216922@gmail.com>
2026-05-05 05:09:50 +00:00
Hosted Weblate 868ec15b16 Added translation using Weblate (Thai)
Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Anucha Hlownonkor <anucha.initdz@gmail.com>
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: skajmer <skajmer@protonmail.com>
2026-05-03 12:06:39 +02:00
Hosted Weblate 775c4e68fa Translated using Weblate
Currently translated at 81.3% (981 of 1206 strings) (Russian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/

Translated using Weblate

Currently translated at 100.0% (1206 of 1206 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 85.6% (1033 of 1206 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: York <goog10216922@gmail.com>
Co-authored-by: spoon <monkeyfart907@gmail.com>
2026-05-02 04:34:18 +00:00
jeffvli 34e0c4bd4a prevent first lyric line highlight before timestamp (#1965) 2026-05-01 21:33:40 -07:00
jeffvli 323130a877 add toggle for app-suspension for powersave block (#1992) 2026-05-01 21:24:45 -07:00
jeffvli 3b2aab74ac enforce web player seek by seconds when less than 1 (#1993) 2026-05-01 21:12:20 -07:00
Hosted Weblate bc7ef0624b Translated using Weblate
Currently translated at 100.0% (1205 of 1205 strings) (Dutch)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/

Translated using Weblate

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

Co-authored-by: Joren Vansteenkiste <vansteenkiste.joren@telenet.be>
Co-authored-by: bokse <weblate@bokse.nl>
2026-05-01 21:32:24 +02:00
Pedro Daniel Reis 304ce8b881 [UI] Made sidebar image just use flex (#1975)
* made sidebar image just use flex

* force aspect ratio to be square

* prevent image container from expanding

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-05-01 12:32:18 -07:00
Jonne Saloranta 01011a49a2 Replace success toast with info when no songs are added (#1994) 2026-05-01 11:44:15 -07:00
York d24ca04878 fix: detect Homebrew mpv on macOS (#1989) 2026-05-01 11:43:07 -07:00
Hosted Weblate 640d38e5a9 Translated using Weblate
Currently translated at 100.0% (1205 of 1205 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Co-authored-by: Ondo <SparkyOndo@proton.me>
2026-04-30 15:09:57 +00:00
jeffvli ac0c074d4b fix undefined / null parameter string for Subsonic (#1978) 2026-04-28 21:17:44 -07:00
jeffvli 6be5818493 migrate to mantine v9 2026-04-28 21:02:27 -07:00
Hosted Weblate 03edd5a639 Translated using Weblate
Currently translated at 100.0% (1205 of 1205 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 100.0% (1205 of 1205 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% (1205 of 1205 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

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

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: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
2026-04-29 04:45:44 +02:00
jeffvli f5eb3f1488 wrap useHotkeys to disable on command palette open (#1925) 2026-04-28 19:31:41 -07:00
jeffvli 8eab9edb15 fix performance issue related to blurred library header 2026-04-28 19:05:43 -07:00
Mitch Ray fcc69980e4 Stretch the wavesurfer waveform to the full height (#1962)
* Stretch the wavesurfer waveform to the full height

* Add waveform stretch setting
2026-04-27 20:28:03 -07:00
Hosted Weblate 053b78a3fd Translated using Weblate
Currently translated at 100.0% (1203 of 1203 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1203 of 1203 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% (1203 of 1203 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: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-22 11:09:49 +02:00
mathew4 42ded966e4 fix: proper selection of next song when using shuffle and repeat-one (#1932) 2026-04-21 00:10:21 -07:00
Kendall Garner ea9119431c use urlsearchparams instead of qs (#1970) 2026-04-21 00:09:23 -07:00
vimae add0345f10 feat(lyrics): non-active lyric settings (#1954)
* feat: non-active lyric settings
2026-04-21 00:09:03 -07:00
Hosted Weblate e5a8324a79 Translated using Weblate
Currently translated at 99.3% (1193 of 1201 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Co-authored-by: yidaduizuoye <yidaduizuoye@outlook.com>
2026-04-19 06:09:51 +02:00
jeffvli cc4e933c07 fix missing path replacement transformations 2026-04-16 00:29:13 -07:00
Hosted Weblate 382d279dad Translated using Weblate
Currently translated at 100.0% (1201 of 1201 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1201 of 1201 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% (1201 of 1201 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Dylan MONTIGAUD <dylanmontigaud17@gmail.com>
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: Nick <n.grakhov08@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-15 06:10:05 +02:00
York b99899f128 fix MPV visualizer on macOS and handle exclusive mode UX (#1930) 2026-04-13 20:47:03 -07:00
korpseluv f5839bf39c normalize album release types and improve grouping logic (#1892) 2026-04-13 20:40:11 -07:00
Damien Erambert 914ed5b8f3 macOS 26-friendly icon (#1941) 2026-04-13 20:32:21 -07:00
Ross ca0a1569f8 Add everfrost dark and light themes (#1934)
Co-authored-by: Ross <ro@noirlab.edu>
2026-04-13 20:22:37 -07:00
Hosted Weblate 9f10fe398a Translated using Weblate
Currently translated at 58.1% (698 of 1200 strings) (Portuguese (Brazil))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/

Translated using Weblate

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

Co-authored-by: Yuri Shumyatsky <shumy260404@gmail.com>
Co-authored-by: albatrays <weblate.duct925@passmail.net>
2026-04-12 12:09:54 +00:00
Kendall Garner 8869278898 make theme selector serachable 2026-04-10 20:03:50 -07:00
Jeff 16c9e6cc1b Fix various build issues (#1942)
* remove dynamic import for platform features

* increase node memory limit on macOS build

* fix invalid dynamic imports in renderer

* remove discord-rpc import in renderer
2026-04-10 01:54:11 -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
242 changed files with 9719 additions and 4992 deletions
+2 -2
View File
@@ -121,7 +121,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
os: [windows-latest, macos-26, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest'
if: matrix.os == 'macos-26'
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
+2 -2
View File
@@ -115,7 +115,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
os: [windows-latest, macos-26, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
if: matrix.os == 'macos-26'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+2 -1
View File
@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [macos-latest]
os: [macos-26]
steps:
- name: Checkout git repo
@@ -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:
+4 -4
View File
@@ -30,7 +30,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
os: [macos-26, ubuntu-latest, windows-latest]
steps:
- name: Checkout git repo
@@ -65,7 +65,7 @@ jobs:
pnpm run package:linux:pr
- name: Build for MacOS
if: ${{ matrix.os == 'macos-latest' }}
if: ${{ matrix.os == 'macos-26' }}
uses: nick-invision/retry@v3.0.2
with:
timeout_minutes: 30
@@ -86,7 +86,7 @@ jobs:
zip -r dist/linux-binaries.zip dist/*.{AppImage,deb,rpm}
- name: Zip MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }}
if: ${{ matrix.os == 'macos-26' }}
run: |
zip -r dist/macos-binaries.zip dist/*.dmg
@@ -105,7 +105,7 @@ jobs:
path: dist/linux-binaries.zip
- name: Upload MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }}
if: ${{ matrix.os == 'macos-26' }}
uses: actions/upload-artifact@v7
with:
name: macos-binaries
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
os: [windows-latest, macos-26, ubuntu-latest]
steps:
- name: Checkout git repo
@@ -36,7 +36,7 @@ jobs:
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
if: matrix.os == 'macos-26'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2
+4
View File
@@ -169,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.
Binary file not shown.
+6 -4
View File
@@ -40,13 +40,15 @@ mac:
arch:
- arm64
- x64
icon: assets/icons/icon.icns
icon: media/feishin.icon
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -61,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
+6 -3
View File
@@ -40,12 +40,15 @@ mac:
arch:
- arm64
- x64
icon: assets/icons/icon.icns
icon: media/feishin.icon
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext}
toolsets:
appimage: "1.0.2"
appimage: '1.0.2'
npmRebuild: false
publish:
+6 -3
View File
@@ -40,12 +40,15 @@ mac:
arch:
- arm64
- x64
icon: assets/icons/icon.icns
icon: media/feishin.icon
type: distribution
hardenedRuntime: false
identity: "-"
identity: '-'
gatekeeperAssess: false
notarize: false
extendInfo:
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +63,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',
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><g style="display:inline" transform="translate(-53.452 -43.352)scale(1.11813)"><circle cx="256" cy="240.312" r="21.5" style="opacity:1;fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.19597;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke;filter:url(#filter249)"/><path d="M220.85 277.951 183.5 315.6l36 36.1 20-19.7s5.856-6.2 16.5-6.2 16.5 6.2 16.5 6.2l20 19.7 36-36.1-37.35-37.649A51.5 51.5 0 0 1 256 291.812a51.5 51.5 0 0 1-35.15-13.86" style="opacity:1;fill:#000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter249)"/><path d="M256 145.4a25.7 25.7 0 0 0-18.229 7.551L66.97 323.47A25.42 25.42 0 0 0 59.5 341.5c0 14.083 11.417 25.5 25.5 25.5a25.42 25.42 0 0 0 18.031-7.469l103.895-103.597a51.5 51.5 0 0 1-2.426-15.621 51.5 51.5 0 0 1 51.5-51.5 51.5 51.5 0 0 1 51.5 51.5 51.5 51.5 0 0 1-2.426 15.62L408.97 359.532A25.42 25.42 0 0 0 427 367c14.083 0 25.5-11.417 25.5-25.5a25.42 25.42 0 0 0-7.469-18.031L274.23 152.95a25.7 25.7 0 0 0-18.229-7.55" style="display:inline;opacity:1;fill:#000;fill-opacity:1;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;paint-order:markers fill stroke;filter:url(#filter249)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+202
View File
@@ -0,0 +1,202 @@
{
"fill-specializations" : [
{
"value" : {
"linear-gradient" : [
"display-p3:0.87416,0.87416,0.87416,1.00000",
"display-p3:0.99575,0.99575,0.99575,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
}
},
{
"appearance" : "dark",
"value" : "system-dark"
}
],
"groups" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"blur-material-specializations" : [
{
"value" : 0.7
},
{
"appearance" : "dark",
"value" : 0.7
},
{
"appearance" : "tinted",
"value" : null
}
],
"hidden" : false,
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"fill-specializations" : [
{
"value" : {
"solid" : "extended-gray:0.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"linear-gradient" : [
"display-p3:0.78674,0.78674,0.78674,1.00000",
"display-p3:0.87416,0.87416,0.87416,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0
}
}
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "gray:1.00000,1.00000"
}
}
],
"glass-specializations" : [
{
"value" : true
},
{
"appearance" : "dark",
"value" : true
},
{
"appearance" : "tinted",
"value" : true
}
],
"hidden" : false,
"image-name" : "feishin.svg",
"name" : "feishin",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.79,
"translation-in-points" : [
18,
-2
]
}
}
],
"lighting-specializations" : [
{
"value" : "individual"
},
{
"appearance" : "tinted",
"value" : "combined"
}
],
"position" : {
"scale" : 2.2,
"translation-in-points" : [
0,
0
]
},
"shadow-specializations" : [
{
"value" : {
"kind" : "neutral",
"opacity" : 1
}
},
{
"appearance" : "dark",
"value" : {
"kind" : "layer-color",
"opacity" : 0.5
}
},
{
"appearance" : "tinted",
"value" : {
"kind" : "neutral",
"opacity" : 1
}
}
],
"specular-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : true
}
],
"translucency-specializations" : [
{
"value" : {
"enabled" : true,
"value" : 0.29
}
},
{
"appearance" : "dark",
"value" : {
"enabled" : false,
"value" : 0.29
}
},
{
"appearance" : "tinted",
"value" : {
"enabled" : true,
"value" : 0.5
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
+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": "^9.1.1",
"@mantine/core": "^9.1.1",
"@mantine/dates": "^9.1.1",
"@mantine/form": "^9.1.1",
"@mantine/hooks": "^9.1.1",
"@mantine/modals": "^9.1.1",
"@mantine/notifications": "^9.1.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.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.8",
"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": [
+2461 -2464
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')),
+42 -7
View File
@@ -139,7 +139,9 @@
"lyricOffset": "demora de la lletra (ms)",
"showLyricMatch": "mosta coincidències de lletres",
"showLyricProvider": "mostra el proveïdor de la lletra",
"lyricGap": "espera entre lletres"
"lyricGap": "espera entre lletres",
"lyricOpacityNonActive": "Opacitat de la lletra inactiva",
"lyricScaleNonActive": "Escala de la lletra inactiva"
},
"lyrics": "lletres",
"visualizer": "visualitzador",
@@ -337,7 +339,8 @@
"filter_single": "senzill",
"filter_multiple": "multi",
"rename": "reanomena",
"newVersionAvailable": "hi ha una nova versió disponible"
"newVersionAvailable": "hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats"
},
"entity": {
"album_one": "àlbum",
@@ -406,7 +409,8 @@
"input_skipDuplicates": "salta't els duplicats",
"success": "s'ha afegit $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"create": "crea $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "cerca $t(entity.playlist, {\"count\": 2}) o escriu per crear-ne una de nova"
"searchOrCreate": "cerca $t(entity.playlist, {\"count\": 2}) o escriu per crear-ne una de nova",
"noneAdded": "no s'han afegit pistes a la $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -506,6 +510,9 @@
"export": "exporta la lletra",
"input_synced": "exporta la lletra sincronitzada",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Emissora de ràdio actualitzada amb èxit"
}
},
"action": {
@@ -522,7 +529,10 @@
"goToPage": "anar a la pàgina",
"openIn": {
"lastfm": "Obrir a Last.fm",
"musicbrainz": "Obrir a MusicBrainz"
"musicbrainz": "Obrir a MusicBrainz",
"listenbrainz": "Obre a ListenBrainz",
"qobuz": "Obre a Qobuz",
"spotify": "Obre a Spotify"
},
"deselectAll": "deselecciona-ho tot",
"viewPlaylists": "veure $t(entity.playlist, {\"count\": 2})",
@@ -597,7 +607,7 @@
"artistConfiguration": "configuració de la pàgina de l'artista de l'àlbum",
"artistConfiguration_description": "configura quins elements es mostren i el seu ordre de la pàgina de l'artista de l'àlbum",
"audioExclusiveMode": "mode d'àudio exclusiu",
"audioExclusiveMode_description": "activa el mode d'àudio exclusiu. En aquest mode, el sistema normalment estarà bloquejat i només mpv podrà emetre àudio",
"audioExclusiveMode_description": "activa el mode d'àudio exclusiu. En aquest mode, el sistema normalment estarà bloquejat i només mpv podrà emetre àudio. El sistema visualitzador de captura d'àudio no funcionarà mentre això estigui activat",
"buttonSize": "mida dels botons de la barra de reproducció",
"buttonSize_description": "la mida dels botons de la barra de reproducció",
"clearCache": "neteja la memòria del navegador",
@@ -910,7 +920,25 @@
"primaryShade": "ombra primària",
"primaryShade_description": "substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari",
"playerItemConfiguration_description": "configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "configuració d'elements del jugador"
"playerItemConfiguration": "configuració d'elements del jugador",
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "mostra enllaços a ListenBrainz",
"qobuz_description": "mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
"qobuz": "mostra enllaços a Qobuz",
"spotify_description": "mostra enllaços a Spotify a les pàgines d'artista/àlbum",
"spotify": "mostra enllaços a Spotify",
"nativeSpotify_description": "obre amb Spotify en lloc del vostre navegador",
"nativeSpotify": "fes servir Spotify",
"playerbarWaveformStretch": "extensió de la forma d'ona",
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
"sidePlayQueueLayout": "disposició de la cua de reproducció lateral",
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
"sidePlayQueueLayout_optionVertical": "vertical",
"waveformLoadingDelay": "demora de càrrega de la forma d'ona",
"waveformLoadingDelay_description": "demora en segons abans de carregar la forma d'ona. incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
"preventSuspendOnPlayback_description": "evita que l'aplicació quedi suspesa mentre es reprodueix música",
"preventSuspendOnPlayback": "evita la suspensió durant la reproducció"
},
"table": {
"column": {
@@ -1360,6 +1388,13 @@
}
},
"pasteGradient": "enganxa degradat",
"pasteGradientPlaceholder": "enganxa el degradat JSON aquí..."
"pasteGradientPlaceholder": "enganxa el degradat JSON aquí...",
"systemAudioConsentAllow": "permetre",
"systemAudioConsentBody": "el visualitzador necessita accés a l'àudio del sistema per funcionar",
"systemAudioConsentDecline": "denega",
"systemAudioConsentTitle": "Voleu permetre accés al sistema d'àudio?",
"systemAudioCaptureFailed": "No s'ha pogut iniciar la captura: {{message}}",
"systemAudioNoAudioTrack": "No s'ha tornat cap pista d'àudio. Comproveu que la captura d'àudio estigui activada quan se sol·liciti.",
"systemAudioExclusiveModeNotSupported": "El visualitzador no està disponible amb el mode d'àudio exclusiu activat. Desactiveu-lo a la configuració d'MPV i torneu-ho a intentar."
}
}
+22 -5
View File
@@ -85,7 +85,7 @@
"hotkey_zoomIn": "přiblížení",
"scrobble_description": "scrobblovat přehrání na váš multimediální server",
"hotkey_browserForward": "vpřed v prohlížeči",
"audioExclusiveMode_description": "zapnout režim výhradního výstupu. V tomto režimu bude obvykle v systému schopný přehrávat zvuk pouze přehrávač mpv",
"audioExclusiveMode_description": "zapnout režim výhradního výstupu. v tomto režimu bude obvykle v systému schopný přehrávat zvuk pouze přehrávač mpv. záznam systémového zvuku ve vizualizéru nebude fungovat",
"discordUpdateInterval": "interval aktualizací {{discord}} rich presence",
"themeLight": "motiv (světlý)",
"fontType_optionBuiltIn": "vestavěné písmo",
@@ -425,7 +425,11 @@
"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."
"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.",
"playerbarWaveformStretch": "natáhnutí vlnové křivky",
"playerbarWaveformStretch_description": "natáhně vlnovou křivku tak, aby vyplnila dostupný prostor",
"preventSuspendOnPlayback_description": "zabránit aplikaci v uspání během přehrávání hudby",
"preventSuspendOnPlayback": "zabránit uspání při přehrávání"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -825,7 +829,9 @@
"lyricGap": "mezera textů",
"dynamicImageBlur": "velikost rozostření obrázku",
"dynamicIsImage": "povolit obrázek na pozadí",
"lyricOffset": "posunutí textů (ms)"
"lyricOffset": "posunutí textů (ms)",
"lyricOpacityNonActive": "neprůhlednost neaktivních textů",
"lyricScaleNonActive": "velikost neaktivních textů"
},
"upNext": "další",
"lyrics": "texty",
@@ -1041,7 +1047,8 @@
"input_skipDuplicates": "přeskočit duplicity",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "vytvořit $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "vyhledejte $t(entity.playlist, {\"count\": 2}) nebo pište pro vytvoření nového"
"searchOrCreate": "vyhledejte $t(entity.playlist, {\"count\": 2}) nebo pište pro vytvoření nového",
"noneAdded": "do $t(entity.playlist, {\"count\": 1}) '{{playlist}}' nebyly přidány žádné skladby"
},
"updateServer": {
"title": "upravit server",
@@ -1110,6 +1117,9 @@
"export": "exportovat texty",
"input_synced": "exportovat synchronizované texty",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stanice rádia úspěšně upravena"
}
},
"entity": {
@@ -1378,6 +1388,13 @@
}
},
"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",
"systemAudioExclusiveModeNotSupported": "Vizualizér není dostupný při zapnutém režimu výhradního výstupu zvuku. Zakažte Režim výhradního výstupu zvuku v nastavení MPV a zkuste to znovu."
}
}
+40 -27
View File
@@ -242,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})",
@@ -273,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": {
@@ -357,6 +357,9 @@
"input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren",
"input_synced": "Synchronisierte Songtexte exportieren"
},
"editRadioStation": {
"success": "Radiosender erfolgreich aktualisiert"
}
},
"entity": {
@@ -426,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)",
@@ -450,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)",
@@ -493,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",
@@ -716,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": {
@@ -766,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",
@@ -789,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",
@@ -1009,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",
@@ -1017,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",
@@ -1121,7 +1125,13 @@
"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"
"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",
@@ -1211,7 +1221,7 @@
"dualVertical": "Dual-Vertikal"
}
},
"minimumFrequency": "Mindestfrequenz",
"minimumFrequency": "Minimale Frequenz",
"minimumDecibels": "Minimale Dezibel",
"visualizerType": "Visualizer Art",
"cyclePresets": "Vorlagen durchrotieren",
@@ -1246,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",
@@ -1262,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",
@@ -1273,17 +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",
"fadePeaks": "Spitzen abblenden",
"showPeaks": "Spitzen anzeigen"
"showPeaks": "Spitzen anzeigen",
"systemAudioConsentAllow": "Erlauben",
"systemAudioConsentDecline": "Ablehnen",
"frequencyScale": "Frequenzskala"
}
}
+18 -1
View File
@@ -347,6 +347,7 @@
"input_skipDuplicates": "skip duplicates",
"searchOrCreate": "search $t(entity.playlist, {\"count\": 2}) or type to create a new one",
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"noneAdded": "no tracks were added to $t(entity.playlist, {\"count\": 1}) '{{playlist}}'",
"title": "add to $t(entity.playlist, {\"count\": 1})"
},
"createPlaylist": {
@@ -364,6 +365,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",
@@ -537,6 +541,8 @@
"lyricOffset": "lyrics offset (ms)",
"lyricGap": "lyric gap",
"lyricSize": "lyric size",
"lyricOpacityNonActive": "non-active lyric opacity",
"lyricScaleNonActive": "non-active lyric scale",
"opacity": "opacity",
"showLyricMatch": "show lyric match",
"showLyricProvider": "show lyric provider",
@@ -758,7 +764,7 @@
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
"audioDevice_description": "select the audio device to use for playback",
"audioDevice": "audio device",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioExclusiveMode_description": "enable exclusive output mode. in this mode, the system is usually locked out, and only mpv will be able to output audio. visualizer system audio capture will not work while this is enabled",
"audioExclusiveMode": "audio exclusive mode",
"audioPlayer_description": "select the audio player to use for playback",
"audioPlayer": "audio player",
@@ -980,6 +986,8 @@
"playerbarWaveformBarWidth": "waveform bar width",
"playerbarWaveformGap": "waveform gap",
"playerbarWaveformRadius": "waveform radius",
"playerbarWaveformStretch": "waveform stretch",
"playerbarWaveformStretch_description": "stretches the waveform to fill the available space",
"preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available",
"preferLocalLyrics": "prefer local lyrics",
"showLyricsInSidebar_description": "a panel will be added to the attached play queue that displays the lyrics",
@@ -1000,6 +1008,8 @@
"audioFadeOnStatusChange_description": "enables fade out and fade in when play/pause status changes",
"preventSleepOnPlayback_description": "prevent the display from sleeping while music is playing",
"preventSleepOnPlayback": "prevent sleep on playback",
"preventSuspendOnPlayback_description": "prevent the application from suspending while music is playing",
"preventSuspendOnPlayback": "prevent suspend on playback",
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
"remotePassword": "remote control server password",
"remotePort_description": "sets the port for the remote control server",
@@ -1211,6 +1221,13 @@
"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.",
"systemAudioExclusiveModeNotSupported": "Visualizer is unavailable while audio exclusive mode is enabled. Disable Audio Exclusive Mode in MPV settings and try again.",
"visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)",
+22 -5
View File
@@ -83,7 +83,7 @@
"replayGainClipping": "recortar {{ReplayGain}}",
"hotkey_zoomIn": "ampliar",
"scrobble_description": "hace scrobble de las reproducciones en tu servidor de medios",
"audioExclusiveMode_description": "activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio",
"audioExclusiveMode_description": "activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio. El sistema visualizador de captura de audio no funcionará mientras esto esté activado",
"discordUpdateInterval": "intervalo de actualización del estado de actividad de {{discord}}",
"themeLight": "tema (claro)",
"fontType_optionBuiltIn": "fuente incorporada",
@@ -425,7 +425,11 @@
"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."
"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.",
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
"preventSuspendOnPlayback": "Evitar la suspensión durante la reproducción",
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música"
},
"action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -772,7 +776,9 @@
"lyricGap": "desfase de letra",
"dynamicImageBlur": "tamaño de desenfoque de imagen",
"dynamicIsImage": "habilitar imagen de fondo",
"lyricOffset": "desplazamiento de letras (ms)"
"lyricOffset": "desplazamiento de letras (ms)",
"lyricScaleNonActive": "Escala de letra no activa",
"lyricOpacityNonActive": "Opacidad de letra no activa"
},
"lyrics": "letras",
"related": "relacionado",
@@ -932,7 +938,8 @@
"input_skipDuplicates": "saltar duplicados",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "Crear $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Buscar $t(entity.playlist, {\"count\": 2}) o escribir para crear uno nuevo"
"searchOrCreate": "Buscar $t(entity.playlist, {\"count\": 2}) o escribir para crear uno nuevo",
"noneAdded": "Ninguna pista fue añadida a $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"updateServer": {
"title": "actualizar servidor",
@@ -1001,6 +1008,9 @@
"export": "Exportar letras",
"input_synced": "Exportar letras sincronizadas",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Estación de radio actualizada con éxito"
}
},
"table": {
@@ -1378,6 +1388,13 @@
"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.",
"systemAudioExclusiveModeNotSupported": "El visualizador no está disponible mientras el modo de audio exclusivo esté activado. Desactiva el modo de audio exclusivo en la configuración de MPV e inténtalo de nuevo."
}
}
+100 -12
View File
@@ -157,7 +157,8 @@
"doNotShowAgain": "ez erakutsi hau berriro",
"numberOfResults": "{{numberOfResults}} emaitza",
"rename": "berrizendatu",
"newVersionAvailable": "bertsio berri bat eskuragarri dago"
"newVersionAvailable": "bertsio berri bat eskuragarri dago",
"gridRows": "sareta-errenkadak"
},
"player": {
"repeat": "errepikatu",
@@ -409,7 +410,8 @@
"fromYear": "urtetik aurrera",
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "eta",
"matchOr": "edo"
"matchOr": "edo",
"sortName": "izenaren arabera ordenatu"
},
"setting": {
"hotkey_playbackPause": "pausatu",
@@ -574,7 +576,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)",
@@ -643,7 +645,7 @@
"passwordStore_description": "zein pasahitz/sekretu biltegi erabili. aldatu hau pasahitzak gordetzeko arazoak badituzu",
"playerFilters": "Iragazi ilarako abestiak",
"sidePlayQueueStyle_description": "alboko erreprodukzio-ilararen estiloa ezartzen du",
"mediaSession_description": "Windows Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz (Windows bakarrik)",
"mediaSession_description": "Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz",
"sidePlayQueueStyle": "alboko erreprodukzio-ilarako estiloa",
"skipPlaylistPage": "saltatu erreprodukzio-zerrenda orria",
"startMinimized_description": "abiarazi aplikazioa sistemaren erretiluan",
@@ -688,7 +690,45 @@
"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",
"listenbrainz": "erakutsi ListenBrainz estekak",
"sidebarPlaylistSorting": "alboko barrako erreprodukzio-zerrenda ordenatzea",
"sidebarPlaylistListFilterRegex_description": "ezkutatu alboko barran adierazpen erregular honekin bat datozen erreprodukzio-zerrendak",
"sidebarPlaylistListFilterRegex_placeholder": "adib. ^Eguneroko Nahasketa.*",
"sidebarPlaylistListFilterRegex": "erreprodukzio-zerrenda iragazteko adierazpen erregularra",
"sidePlayQueueLayout": "alboko erreprodukzio-ilararen diseinua",
"sidePlayQueueLayout_description": "erantsitako alboko erreprodukzio-ilararen diseinua ezartzen du",
"sidePlayQueueLayout_optionHorizontal": "horizontala",
"sidePlayQueueLayout_optionVertical": "bertikala",
"skipDuration_description": "erreproduzitzailearen barran saltatzeko botoiak erabiltzean saltatzeko iraupena ezartzen du",
"skipPlaylistPage_description": "erreprodukzio-zerrenda batera nabigatzean, joan erreprodukzio-zerrendako abestien zerrendara lehenetsitako orrialdera joan beharrean",
"translationApiKey_description": "itzulpenerako api gakoa (zerbitzu globalaren amaiera-puntua soilik)"
},
"form": {
"addServer": {
@@ -705,7 +745,9 @@
"success": "zerbitzaria behar bezala gehitu da",
"input_preferInstantMix": "nahiago izan berehalako nahasketa",
"input_preferInstantMixDescription": "erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu",
"input_remoteUrl": "URL publikoa"
"input_remoteUrl": "URL publikoa",
"input_preferRemoteUrl": "url publikoa nahiago izan",
"input_remoteUrlPlaceholder": "aukerakoa: kanpoko funtzioetarako url publikoa"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
@@ -713,7 +755,8 @@
"input_skipDuplicates": "saltatu bikoiztuak",
"title": "gehitu $t(entity.playlist, {\"count\": 1})-(a)ri",
"create": "sortu $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "bilatu $t(entity.playlist, {\"count\": 2}) edo idatzi berri bat sortzeko"
"searchOrCreate": "bilatu $t(entity.playlist, {\"count\": 2}) edo idatzi berri bat sortzeko",
"noneAdded": "ez da pistarik gehitu honi $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -734,7 +777,9 @@
"success": "partekatzeko esteka arbelera kopiatu da (edo egin klik hemen irekitzeko)",
"expireInvalid": "iraungitze-data etorkizunean izan behar da",
"allowDownloading": "baimendu deskargatzea",
"createFailed": "partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)"
"createFailed": "partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)",
"copyToClipboard": "Kopiatu arbelean: Ctrl+C, Enter",
"successMustClick": "partekatzea behar bezala sortu da. egin klik hemen irekitzeko"
},
"deletePlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala ezabatu da",
@@ -943,7 +988,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})",
@@ -961,7 +1007,10 @@
"recentReleases": "azken argitalpenak",
"viewDiscography": "ikusi diskografia",
"groupingTypeAll": "argitalpen mota guztiak",
"groupingTypePrimary": "argitalpen mota nagusiak"
"groupingTypePrimary": "argitalpen mota nagusiak",
"favoriteSongs": "abesti gogokoenak",
"topSongsCommunity": "komunitatea",
"topSongsPersonal": "pertsonala"
},
"itemDetail": {
"copyPath": "kopiatu bidea arbelean",
@@ -979,6 +1028,13 @@
},
"radioList": {
"title": "irrati-kateak"
},
"releasenotes": {
"noStableReleaseToCompare": "ez dago bertsio egonkorrik alderatzeko"
},
"windowBar": {
"paused": "(Pausatuta) ",
"privateMode": "(Modu pribatua)"
}
},
"releaseType": {
@@ -1011,7 +1067,19 @@
"notContains": "ez dauka",
"startsWith": "honekin hasten da",
"endsWith": "honekin amaitzen da",
"isNot": "ez da"
"isNot": "ez da",
"afterDate": "(data) ondoren da",
"after": "ondoren da",
"before": "lehenago da",
"beforeDate": "(data) baino lehenagokoa da",
"inTheLast": "azkenengoan dago",
"inPlaylist": "bertan dago",
"inTheRange": "tartean dago",
"inTheRangeDate": "(data) tartean dago",
"isGreaterThan": "hau baino handiagoa da",
"isLessThan": "hau baino gutxiago da",
"notInPlaylist": "ez dago barruan",
"notInTheLast": "ez dago azkenengoan"
},
"visualizer": {
"general": "Orokorra",
@@ -1112,6 +1180,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"
}
}
+29 -15
View File
@@ -246,10 +246,10 @@
"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)\"",
"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 pistes uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
"badValue": "option {{value}} invalide. cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
"multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs titres qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"saveQueueFailed": "échec de l'enregistrement de la file d'attente",
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
"noNetwork": "serveur indisponible",
@@ -340,7 +340,9 @@
"lyricGap": "espacement des lettres",
"dynamicIsImage": "activer l'image d'arrière-plan",
"dynamicImageBlur": "intensité du flou sur l'image d'arrière-plan",
"lyricOffset": "décalage des paroles (ms)"
"lyricOffset": "décalage des paroles (ms)",
"lyricScaleNonActive": "mise à l'échelle des paroles inactives",
"lyricOpacityNonActive": "opacité des paroles inactive"
},
"upNext": "à suivre",
"lyrics": "paroles",
@@ -477,7 +479,7 @@
"groupingTypePrimary": "types de parution principale",
"topSongsCommunity": "communauté",
"topSongsPersonal": "personnel",
"favoriteSongsFrom": "titres favori de {{title}}"
"favoriteSongsFrom": "titres favoris de {{title}}"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
@@ -520,7 +522,7 @@
},
"setting": {
"audioDevice_description": "sélectionnez le périphérique audio à utiliser pour la lecture",
"audioExclusiveMode_description": "activer le mode de sortie exclusif. Dans ce mode, le système est généralement verrouillé et seul mpv pourra émettre de l'audio",
"audioExclusiveMode_description": "activer le mode de sortie exclusif. Dans ce mode, le système est généralement verrouillé et seul mpv pourra émettre de l'audio. La capture audio du système de visualisation ne fonctionnera pas lorsque cette option est activée",
"audioPlayer_description": "sélectionnez le lecteur audio à utiliser pour la lecture",
"crossfadeDuration_description": "définit la durée du fondu enchaîné",
"audioDevice": "périphérique audio",
@@ -891,7 +893,9 @@
"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"
"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": {
@@ -912,7 +916,7 @@
"ignoreCors": "ignorer cors $t(common.restartRequired)",
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe",
"input_preferInstantMix": "préférer le mix instantané",
"input_preferInstantMixDescription": "utiliser uniquement le mix instantané pour jouer des pistes similaires. utile si vous avez des plugins qui modifient ce comportement",
"input_preferInstantMixDescription": "utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement",
"input_preferRemoteUrl": "préférer une URL publique",
"input_remoteUrl": "URL publique",
"input_remoteUrlPlaceholder": "optionnel : URL publique pour les fonctionnalités externes"
@@ -1000,6 +1004,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": {
@@ -1090,7 +1097,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",
@@ -1232,12 +1239,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",
@@ -1247,7 +1254,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",
@@ -1272,7 +1279,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",
@@ -1377,6 +1384,13 @@
},
"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}}",
"systemAudioExclusiveModeNotSupported": "Le visualiseur est indisponible lorsque le mode audio exclusif est activé. Désactivez ce mode dans les paramètres MPV et réessayez."
}
}
+11 -2
View File
@@ -1037,7 +1037,7 @@
},
"updateServer": {
"title": "サーバーをアップデート",
"success": "サーバーがアップデートされました"
"success": "サーバーの更新に成功しました"
},
"queryEditor": {
"input_optionMatchAll": "すべて一致",
@@ -1102,6 +1102,9 @@
},
"saveQueue": {
"success": "プレイキューをサーバーに保存しました"
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
}
},
"entity": {
@@ -1332,6 +1335,12 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioConsentAllow": "許可",
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
"systemAudioConsentDecline": "拒否",
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
}
}
+22 -6
View File
@@ -143,7 +143,7 @@
"doNotShowAgain": "niet opnieuw tonen",
"externalLinks": "externe links",
"faster": "sneller",
"preview": "voorvertoning",
"preview": "voorbeeld",
"private": "privé",
"public": "publiekelijk",
"recordLabel": "platenlabel",
@@ -152,7 +152,7 @@
"sort": "sorteer",
"trackGain": "trackvolume",
"trackPeak": "piekniveau",
"clean": "schoon",
"clean": "opschonen",
"gridRows": "rasterrijen",
"tableColumns": "tabelkolommen",
"itemsMore": "{{count}} meer",
@@ -283,7 +283,9 @@
"unsynchronized": "niet gesynchronizeerd",
"useImageAspectRatio": "gebruik aspect ratio van de afbeelding",
"lyricOffset": "songtekst-vertraging (ms)",
"showLyricProvider": "toon songtekstaanbieder"
"showLyricProvider": "toon songtekstaanbieder",
"lyricOpacityNonActive": "opaciteit van niet-actieve songtekst",
"lyricScaleNonActive": "schaal niet-actieve songtekst"
},
"lyrics": "liedtekst",
"related": "gerelateerd",
@@ -680,7 +682,7 @@
"artistReleaseTypeConfiguration_description": "configureer welke uitgavesoorten worden getoond op de albumartiestpagina en in welke volgorde",
"audioDevice_description": "kies het audioapparaat dat wordt gebruikt om af te spelen",
"audioDevice": "audioapparaat",
"audioExclusiveMode_description": "schakel exclusieve uitvoermodus in. In deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren",
"audioExclusiveMode_description": "schakel exclusieve uitvoermodus in. in deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren. geluidsopname in de visualiseerder zal niet werken als dit is ingeschakeld",
"audioExclusiveMode": "audio-exclusieve modus",
"audioPlayer_description": "kies de audiospeler om te gebruiken bij het afspelen",
"audioPlayer": "audiospeler",
@@ -985,7 +987,11 @@
"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"
"sidePlayQueueLayout_optionVertical": "verticaal",
"playerbarWaveformStretch": "rekking van golfvorm",
"playerbarWaveformStretch_description": "rekt de golfvorm om de beschikbare ruimte op te vullen",
"waveformLoadingDelay": "laadvertraging van golfvorm",
"waveformLoadingDelay_description": "vertraging in seconden voordat de golfvorm wordt geladen. vergroot deze waarde als je stotteringen ervaart bij gebruik van de webspeler."
},
"form": {
"addServer": {
@@ -1094,6 +1100,9 @@
},
"saveQueue": {
"success": "wachtrij opgeslagen op server"
},
"editRadioStation": {
"success": "radiozender succesvol bijgewerkt"
}
},
"player": {
@@ -1353,6 +1362,13 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioConsentAllow": "Toestaan",
"systemAudioConsentBody": "De visualiseerder heeft toegang tot het audiosysteem nodig om te werken",
"systemAudioConsentDecline": "Weigeren",
"systemAudioConsentTitle": "Toegang tot audiosysteem toestaan?",
"systemAudioCaptureFailed": "Kon opname niet starten: {{message}}",
"systemAudioNoAudioTrack": "Geen geluidsspoor ontvangen. Verifieer dat audio-opname is ingeschakeld als daar om wordt gevraagd.",
"systemAudioExclusiveModeNotSupported": "De visualiseerder is niet beschikbaar als audio-exclusieve modus is ingeschakeld. Schakel audio-exclusieve modus in de MPV-instellingen uit en probeer het opnieuw."
}
}
+26 -6
View File
@@ -169,7 +169,8 @@
"filter_single": "single",
"rename": "zmień nazwę",
"newVersionAvailable": "nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników"
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "grupowanie"
},
"entity": {
"genre_one": "gatunek",
@@ -351,7 +352,8 @@
"input_skipDuplicates": "pomiń duplikaty",
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "utwórz $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "wyszukaj $t(entity.playlist, {\"count\": 2}) lub wpisz, aby utworzyć nową"
"searchOrCreate": "wyszukaj $t(entity.playlist, {\"count\": 2}) lub wpisz, aby utworzyć nową",
"noneAdded": "nie dodano utworów do $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"updateServer": {
"title": "uaktualnij serwer",
@@ -420,6 +422,9 @@
"export": "eksportuj tekst",
"input_synced": "eksportuj zsynchronizowany tekst",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stacja radiowa zaktualizowana pomyślnie"
}
},
"page": {
@@ -438,7 +443,9 @@
"lyricGap": "odstępy tekstu",
"dynamicImageBlur": "rozmiar rozmycia obrazu",
"dynamicIsImage": "włącz obraz w tle",
"lyricOffset": "opóźnienie tekstów (ms)"
"lyricOffset": "opóźnienie tekstów (ms)",
"lyricOpacityNonActive": "przezroczystość nieaktywnego tekstu",
"lyricScaleNonActive": "skala nieaktywnego tekstu"
},
"upNext": "następne",
"lyrics": "tekst",
@@ -698,7 +705,7 @@
"hotkey_favoriteCurrentSong": "ulubiona $t(common.currentSong)",
"hotkey_zoomIn": "przybliż",
"hotkey_browserForward": "przeglądarka w przód",
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv",
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv. wizualizer i przechwytywanie audio przez sysytem nie będzie działać gdy jest to włączone",
"discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence",
"fontType_optionBuiltIn": "wbudowana czcionka",
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
@@ -1058,7 +1065,13 @@
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
"sidePlayQueueLayout_optionHorizontal": "poziomy",
"sidePlayQueueLayout_optionVertical": "pionowy"
"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.",
"playerbarWaveformStretch": "rozciąganie przebiegu",
"playerbarWaveformStretch_description": "rozciąga przebieg aby wypełnić dostępną przestrzeń",
"preventSuspendOnPlayback_description": "powstrzymuj wstrzymanie podczas odtwarzania muzyki",
"preventSuspendOnPlayback": "powstrzymuje wstrzymanie przy odtwarzaniu"
},
"table": {
"config": {
@@ -1375,6 +1388,13 @@
},
"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.",
"systemAudioExclusiveModeNotSupported": "Wizualizer jest niedostępny w gdy tryb wyłącznie z dźwiękiek jest włączony. Wyłącz tryb wyłącznie audio w ustawieniach MPV i spróbuj ponownie."
}
}
+14 -3
View File
@@ -91,7 +91,7 @@
"share": "compartilhar",
"close": "fechar",
"translation": "tradução",
"albumGain": "ganho do album",
"albumGain": "ganho do álbum",
"trackPeak": "peak da faixa",
"albumPeak": "pico do álbum",
"trackGain": "ganho da faixa",
@@ -121,10 +121,21 @@
"removeFromFavorites": "remover de $t(entity.favorite, {\"count\": 2})",
"openIn": {
"lastfm": "Abrir em Last.fm",
"musicbrainz": "Abrir em MusicBrainz"
"musicbrainz": "Abrir em MusicBrainz",
"listenbrainz": "Abrir no ListenBrainz",
"qobuz": "Abrir no Qobuz"
},
"toggleSmartPlaylistEditor": "alternar editor $t(entity.smartPlaylist)",
"moveToNext": "mover para o próximo"
"moveToNext": "mover para o próximo",
"addOrRemoveFromSelection": "adicionar ou remover da seleção",
"goToCurrent": "ir para o item atual",
"createRadioStation": "criar $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "deletar $t(entity.radioStation, {\"count\": 1})",
"selectAll": "selecionar tudo",
"holdToMoveToTop": "segure para ir ao topo",
"moveItems": "mover itens",
"viewMore": "ver mais",
"openApplicationDirectory": "abrir a pasta do aplicativo"
},
"form": {
"deletePlaylist": {
+47 -13
View File
@@ -18,8 +18,11 @@
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
"removeFromFavorites": "удалить из $t(entity.favorite, {\"count\": 2})",
"openIn": {
"lastfm": "открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz"
"lastfm": "Открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz",
"listenbrainz": "Открыть на ListenBrainz",
"qobuz": "Открыть в Qobuz",
"spotify": "Открыть в Spotify"
},
"moveToNext": "следующий",
"addOrRemoveFromSelection": "добавить или удалить из выделения",
@@ -167,7 +170,8 @@
"explicit": "нецензурная лексика",
"externalLinks": "внешние ссылки",
"explicitStatus": "признак нецензурного контента",
"newVersionAvailable": "доступна новая версия"
"newVersionAvailable": "доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов"
},
"entity": {
"album_one": "альбом",
@@ -435,7 +439,17 @@
"home": "$t(common.home)",
"myLibrary": "Моя библиотека",
"shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})",
"collections": "коллекции"
"collections": "коллекции",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"folders": "$t(entity.folder, {\"count\": 2})",
"genres": "$t(entity.genre, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})"
},
"fullscreenPlayer": {
"config": {
@@ -452,7 +466,9 @@
"useImageAspectRatio": "использовать соотношение сторон изображения",
"lyricGap": "пробел между словами",
"dynamicIsImage": "включить фоновое изображение",
"dynamicImageBlur": "сила размытия изображения"
"dynamicImageBlur": "сила размытия изображения",
"lyricOpacityNonActive": "непрозрачность неактивных слов",
"lyricScaleNonActive": "размер неактивных слов"
},
"upNext": "играет",
"lyrics": "слова",
@@ -475,7 +491,8 @@
"selectMusicFolder": "выбрать папку с музыкой",
"noMusicFolder": "папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок",
"commandPalette": "открыть командную строку"
"commandPalette": "открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
},
"manageServers": {
"title": "сервера",
@@ -510,7 +527,8 @@
"goTo": "перейти в",
"moveToNext": "$t(action.moveToNext)",
"playShuffled": "$t(player.shuffle)",
"moveItems": "$t(action.moveItems)"
"moveItems": "$t(action.moveItems)",
"playSimilarSongs": "$t(player.playSimilarSongs)"
},
"home": {
"mostPlayed": "слушают чаще всего",
@@ -518,7 +536,8 @@
"title": "$t(common.home)",
"explore": "откройте новое",
"recentlyPlayed": "игралось недавно",
"recentlyReleased": "Новинки"
"recentlyReleased": "Новинки",
"genres": "$t(entity.genre, {\"count\": 2})"
},
"albumDetail": {
"moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})",
@@ -552,10 +571,13 @@
},
"genreList": {
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})"
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
},
"trackList": {
"artistTracks": "Треки {{artist}}"
"artistTracks": "Треки {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"globalSearch": {
"commands": {
@@ -616,6 +638,12 @@
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
}
},
"form": {
@@ -656,7 +684,8 @@
"input_skipDuplicates": "не добавлять дубликаты",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
"input_playlists": "$t(entity.playlist, {\"count\": 2})"
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
"noneAdded": "в плейлист $t(entity.playlist, {\"count\": 1}) '{{playlist}}' не было добавлено ни одного трека"
},
"updateServer": {
"title": "обновление сервера",
@@ -725,6 +754,9 @@
"input_played_optionUnplayed": "только не игранные треки",
"input_played_optionPlayed": "только воспроизведённые треки",
"input_genre": "$t(entity.genre, {\"count\": 1})"
},
"editRadioStation": {
"success": "радиостанция успешно обновлена"
}
},
"setting": {
@@ -1053,7 +1085,8 @@
"audioFadeOnStatusChange": "плавное изменение звука",
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
"preventSleepOnPlayback": "не переходить в спящий режим"
"preventSleepOnPlayback": "не переходить в спящий режим",
"discordLinkType_none": "$t(common.none)"
},
"releaseType": {
"secondary": {
@@ -1074,7 +1107,8 @@
"other": "другие",
"broadcast": "транслировать",
"ep": "эп",
"single": "сингл"
"single": "сингл",
"album": "$t(entity.album, {\"count\": 1})"
}
},
"datetime": {
+1
View File
@@ -0,0 +1 @@
{}
+66 -6
View File
@@ -36,7 +36,10 @@
"openApplicationDirectory": "відкрити каталог додатків",
"openIn": {
"lastfm": "Відкрити в Last.fm",
"musicbrainz": "Відкрити в MusicBrainz"
"musicbrainz": "Відкрити в MusicBrainz",
"listenbrainz": "Відкрити у ListenBrainz",
"qobuz": "Відкрити у Qobuz",
"spotify": "Відкрити у Spotify"
}
},
"common": {
@@ -164,7 +167,9 @@
"explicit": "Експліцитний зміст",
"gridRows": "рядки сітки",
"tableColumns": "стовпці таблиці",
"itemsMore": "{{count}} більше"
"itemsMore": "{{count}} більше",
"numberOfResults": "{{numberOfResults}} результатів",
"newVersionAvailable": "доступна нова версія"
},
"entity": {
"album_one": "альбом",
@@ -255,7 +260,9 @@
"serverRequired": "потрібен сервер",
"sessionExpiredError": "ваша сесія закінчилася",
"systemFontError": "сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни"
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "недійсний JSON",
"playbackPausedDueToError": "відтворення було призупинено через помилку"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -281,7 +288,7 @@
"isPublic": "є публічним",
"isRated": "є оціненим",
"isRecentlyPlayed": "нещодавно відтворено",
"lastPlayed": "нещодавно відтворені",
"lastPlayed": "останнє відтворене",
"mostPlayed": "найбільш відтворювані",
"name": "назва",
"note": "примітка",
@@ -301,7 +308,9 @@
"title": "назва",
"toYear": "до року",
"trackNumber": "трек",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "і",
"matchOr": "або"
},
"datetime": {
"minuteShort": "хв.",
@@ -414,7 +423,9 @@
"setExpiration": "встановити термін дії",
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)"
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)",
"copyToClipboard": "Скопіювати до буфера обміну: Ctrl+C, Enter",
"successMustClick": "посилання успішно створено, натисніть сюди, щоб відкрити"
},
"shuffleAll": {
"title": "відтворити випадково",
@@ -435,6 +446,9 @@
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "приватний режим"
},
"editRadioStation": {
"success": "радіо станція успішно оновлена"
}
},
"player": {
@@ -539,6 +553,52 @@
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "динамічний фон",
"dynamicImageBlur": "розмір розмиття зображення",
"dynamicIsImage": "включити фонове зображення",
"followCurrentLyric": "слідкувати за поточним рядком",
"lyricAlignment": "вирівнювання тексту",
"lyricOffset": "затримка тексту (мс)",
"lyricGap": "розмір між рядками",
"lyricSize": "розмір тексту",
"opacity": "непрозорість",
"showLyricMatch": "показувати збіг тексту пісень",
"showLyricProvider": "показувати джерело тексту пісень",
"synchronized": "синхронізовано",
"unsynchronized": "несинхронізовано",
"useImageAspectRatio": "використовувати співвідношення сторін зображення"
},
"lyrics": "текст пісні",
"related": "пов'язані",
"upNext": "далі",
"visualizer": "візуалізатор",
"noLyrics": "текст пісні не знайдено"
},
"genreList": {
"showAlbums": "показати $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показати $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"globalSearch": {
"commands": {
"goToPage": "перейти до сторінки",
"searchFor": "шукати на {{query}}",
"serverCommands": "команди сервера"
},
"title": "команди"
},
"home": {
"explore": "дослідити з вашої бібліотеки",
"genres": "$t(entity.genre, {\"count\": 2})",
"mostPlayed": "найбільш відтворені",
"newlyAdded": "нещодавно додані релізи",
"recentlyPlayed": "нещодавно відтворені"
}
}
}
+8 -2
View File
@@ -161,7 +161,8 @@
"rename": "重命名",
"filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果"
"numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -609,7 +610,9 @@
"sidePlayQueueLayout": "侧边播放队列布局",
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直"
"sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -1012,6 +1015,9 @@
"input_played_optionPlayed": "仅已播放的曲目",
"input_limit": "有多少首歌?",
"input_played": "播放筛选器"
},
"editRadioStation": {
"success": "电台更新成功"
}
},
"table": {
+22 -5
View File
@@ -233,7 +233,9 @@
"showLyricMatch": "顯示匹配的歌詞",
"dynamicImageBlur": "圖片模糊大小",
"dynamicIsImage": "啟用背景圖片",
"lyricOffset": "歌詞偏移時間 (ms)"
"lyricOffset": "歌詞偏移時間 (ms)",
"lyricOpacityNonActive": "非活躍歌詞的不透明度",
"lyricScaleNonActive": "非活躍歌詞的比例"
},
"lyrics": "歌詞",
"related": "相關",
@@ -433,7 +435,7 @@
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備",
"audioExclusiveMode": "音訊獨佔模式",
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊。視覺化音訊截取在此選項啟用時不會作用",
"audioPlayer": "音訊播放器",
"crossfadeDuration": "淡入淡出持續時間",
"crossfadeDuration_description": "設定淡入淡出持續時間",
@@ -792,7 +794,11 @@
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
"qobuz": "顯示 Qobuz 連結",
"waveformLoadingDelay": "波形載入延遲",
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。"
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
"playerbarWaveformStretch": "波形拉伸",
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
"preventSuspendOnPlayback": "在播放時防止應用程式暫停"
},
"table": {
"config": {
@@ -1042,7 +1048,8 @@
"success": "新增 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "新增到$t(entity.playlist, {\"count\": 1})",
"create": "建立 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目"
"searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目",
"noneAdded": "沒有曲目新增到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -1124,6 +1131,9 @@
"export": "匯出歌詞",
"input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電臺更新成功"
}
},
"releaseType": {
@@ -1332,6 +1342,13 @@
"d": "D",
"z": "Z"
}
}
},
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
"systemAudioConsentTitle": "允許存取系統音訊?",
"systemAudioExclusiveModeNotSupported": "啟用音訊獨佔模式時,視覺化不可用。 在MPV設定中停用音訊獨佔模式,然後再試一次。"
}
}
+40 -5
View File
@@ -1,13 +1,13 @@
import console from 'console';
import { app, ipcMain } from 'electron';
import { rm } from 'fs/promises';
import { access, rm } from 'fs/promises';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { pid } from 'node:process';
import process from 'process';
import { getMainWindow, sendToastToRenderer } from '../../../index';
import { createLog, isWindows } from '../../../utils';
import { createLog, isMacOS, isWindows } from '../../../utils';
import { store } from '../settings';
import { PlayerData } from '/@/shared/types/domain-types';
@@ -69,6 +69,7 @@ const mpvLog = (
};
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MACOS_MPV_BINARY_PATHS = ['/opt/homebrew/bin/mpv', '/usr/local/bin/mpv'];
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
@@ -86,12 +87,38 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
return parameters;
};
const resolveMpvBinaryPath = async (binaryPath?: string) => {
if (binaryPath) {
return binaryPath;
}
if (MPV_BINARY_PATH) {
return MPV_BINARY_PATH;
}
if (!isMacOS()) {
return undefined;
}
for (const candidate of MACOS_MPV_BINARY_PATHS) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next common Homebrew location.
}
}
return undefined;
};
const createMpv = async (data: {
binaryPath?: string;
extraParameters?: string[];
properties?: Record<string, any>;
}): Promise<MpvAPI> => {
const { binaryPath, extraParameters, properties } = data;
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
@@ -99,7 +126,7 @@ const createMpv = async (data: {
{
audio_only: true,
auto_restart: false,
binary: binaryPath || MPV_BINARY_PATH || undefined,
binary: resolvedBinaryPath,
socket: socketPath,
time_update: 1,
},
@@ -437,10 +464,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 {};
+127 -10
View File
@@ -5,6 +5,7 @@ import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
desktopCapturer,
globalShortcut,
ipcMain,
Menu,
@@ -29,7 +30,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 +42,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;
@@ -277,6 +278,13 @@ 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) => {
@@ -333,6 +341,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,
@@ -699,12 +724,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) => {
@@ -712,6 +733,29 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
}
desktopCapturer
.getSources({ thumbnailSize: { height: 0, width: 0 }, types: ['screen'] })
.then((sources) => {
const source = sources[0];
if (!source) {
callback({});
return;
}
callback({ audio: 'loopback', video: source });
})
.catch((err) => {
log.warn('desktopCapturer.getSources failed', err);
callback({});
});
});
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
@@ -782,6 +826,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]: () => {},
@@ -835,6 +890,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) {
@@ -856,12 +931,14 @@ ipcMain.on(
},
);
ipcMain.handle('power-save-blocker-start', () => {
ipcMain.handle('power-save-blocker-start', (_event, { full }: { full: boolean }) => {
if (powerSaveBlockerId !== null) {
return powerSaveBlockerId;
}
powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep');
powerSaveBlockerId = powerSaveBlocker.start(
full ? 'prevent-display-sleep' : 'prevent-app-suspension',
);
return powerSaveBlockerId;
});
@@ -975,3 +1052,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;
+84
View File
@@ -147,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);
@@ -175,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);
@@ -189,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);
@@ -960,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 } }));
},
};
@@ -409,6 +409,8 @@ export const JellyfinController: InternalControllerEndpoint = {
return jfNormalize.album(
{ ...res.body, Songs: songsRes.body.Items },
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
);
},
getAlbumList: async (args) => {
@@ -580,7 +582,8 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;
},
getFolder: async ({ apiClientProps, query }) => {
getFolder: async (args) => {
const { apiClientProps, query } = args;
const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found');
@@ -742,6 +745,8 @@ export const JellyfinController: InternalControllerEndpoint = {
jfNormalize.song(
item as unknown as z.infer<typeof jfType._response.song>,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
);
@@ -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,
@@ -412,7 +496,12 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return res.body.similarSongs.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
);
},
getArtistList: async (args) => {
@@ -482,7 +571,12 @@ export const NavidromeController: InternalControllerEndpoint = {
}
return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server),
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
);
},
getDownloadUrl: SubsonicController.getDownloadUrl,
@@ -547,7 +641,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) => {
@@ -722,7 +833,14 @@ export const NavidromeController: InternalControllerEndpoint = {
return (
(res.body.similarSongs?.song || [])
.filter((song) => song.id !== query.songId)
.map((song) => ssNormalize.song(song, apiClientProps.server)) || []
.map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
) || []
);
},
getSongDetail: async (args) => {
@@ -921,6 +1039,7 @@ export const NavidromeController: InternalControllerEndpoint = {
const res = await NavidromeController.getSongList({
apiClientProps,
context: args.context,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
@@ -1145,7 +1264,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;
@@ -1170,4 +1308,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';
},
};
+30 -3
View File
@@ -1,6 +1,5 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { z } from 'zod';
@@ -377,11 +376,39 @@ axiosClient.interceptors.response.use(
},
);
const keysToSkipEmptyCheck = new Set([
'artist',
'comment',
'genre',
'name',
'query',
'u',
'username',
]);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params, { arrayLimit: 99999, parameterLimit: 99999 });
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
const url = new URLSearchParams(params);
const notNilParams: Record<string, string[]> = {};
for (const [key, value] of url) {
if (!keysToSkipEmptyCheck.has(key) && (value === 'undefined' || value === 'null')) {
continue;
}
let realKey = key;
if (key.includes('[') && key.includes(']')) {
realKey = key.split('[')[0];
}
if (realKey in notNilParams) {
notNilParams[realKey].push(value);
} else {
notNilParams[realKey] = [value];
}
}
return {
params: notNilParams,
@@ -237,6 +237,27 @@ function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
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: {
@@ -487,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,
@@ -1185,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({});
@@ -1994,8 +2015,12 @@ export const SubsonicController: InternalControllerEndpoint = {
},
});
// 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) {
throw new Error('Failed to get transcode decision');
logFn.error(
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
);
return streamUrl;
}
const td = transcodeDecision.body.transcodeDecision;
@@ -2013,20 +2038,14 @@ export const SubsonicController: InternalControllerEndpoint = {
return appendTranscodeParams(streamUrl, format, bitrate);
}
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
query: {
mediaId: id,
mediaType,
offset: 0,
transcodeParams: td.transcodeParams,
},
const transcodeStreamUrl = buildGetTranscodeStreamUrl(server, {
mediaId: String(id),
mediaType: (mediaType ?? 'song') as 'podcast' | 'song',
offset: 0,
transcodeParams: td.transcodeParams,
});
if (transcodeStreamUrl.status !== 200) {
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
return transcodeStreamUrl;
}
return streamUrl;
@@ -2106,6 +2125,7 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await SubsonicController.getSongList({
apiClientProps,
context,
query: {
artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT,
+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;
};
@@ -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',
@@ -6,8 +6,8 @@ import {
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -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;
@@ -385,8 +385,8 @@ const BaseItemGridList = ({
rows,
size = 'default',
}: ItemGridListProps) => {
const rootRef = useRef(null);
const outerRef = useRef(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<FixedSizeList<GridItemProps>>(null);
const { ref: containerRef, width: containerWidth } = useElementSize();
const { focused, ref: containerFocusRef } = useFocusWithin();
@@ -486,7 +486,7 @@ const BaseItemGridList = ({
}, [itemsPerRow, rows?.length, size]);
useLayoutEffect(() => {
const { current: container } = containerRef;
const container = rootRef.current;
if (!container) return;
throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => {
@@ -500,13 +500,15 @@ const BaseItemGridList = ({
current.rowCount !== meta.rowCount
) {
tableMetaRef.current = meta;
container.style.setProperty('--grid-column-count', String(meta.columnCount));
container.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);
container.style.setProperty('--grid-row-count', String(meta.rowCount));
const el = rootRef.current;
if (!el) return;
el.style.setProperty('--grid-column-count', String(meta.columnCount));
el.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);
el.style.setProperty('--grid-row-count', String(meta.rowCount));
setTableMetaVersion((v) => v + 1);
}
});
}, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]);
}, [containerWidth, resolvedItemCount, throttledSetTableMeta]);
const controls = useDefaultItemListControls({
enableMultiSelect,
@@ -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;
@@ -31,6 +31,7 @@ import {
} from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
@@ -49,7 +50,6 @@ import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import {
Album,
AlbumListSort,
@@ -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();
@@ -43,6 +43,7 @@ import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useContainerQuery } from '/@/renderer/hooks';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes';
import {
ArtistItem,
@@ -75,7 +76,6 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import {
Album,
@@ -1214,7 +1214,7 @@ export const AlbumArtistDetailContent = ({
artistSongsLink={artistSongsLink}
onArtistRadio={handleArtistRadio}
/>
<Grid gutter="2xl">
<Grid gap="2xl">
<AlbumArtistMetadataGenres
genres={detailQuery.data?.genres}
order={genresOrder}
@@ -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 = {
@@ -13,6 +13,15 @@ export type GroupingType = 'all' | 'primary';
const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single'];
const getNormalizedReleaseTypes = (album: Album): string[] => {
const rawReleaseTypes = [...(album.releaseTypes || []), album.releaseType || ''];
const normalizedReleaseTypes = rawReleaseTypes
.map((type) => type.trim().toLowerCase())
.filter(Boolean);
return [...new Set(normalizedReleaseTypes)];
};
export const groupAlbumsByReleaseType = (
albums: Album[],
routeId: string,
@@ -44,10 +53,9 @@ export const groupAlbumsByReleaseType = (
}
// Group by all release types
const releaseTypes = album.releaseTypes || [];
if (releaseTypes.length > 0) {
const normalizedTypes = getNormalizedReleaseTypes(album);
if (normalizedTypes.length > 0) {
// Sort release types: primaries first (alphabetically), then secondaries (alphabetically)
const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());
const primaryTypes = normalizedTypes
.filter((type) => PRIMARY_RELEASE_TYPES.includes(type))
.sort();
@@ -92,8 +100,7 @@ export const groupAlbumsByReleaseType = (
return acc;
}
const releaseTypes = album.releaseTypes || [];
const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());
const normalizedTypes = getNormalizedReleaseTypes(album);
let matchedType: null | string = null;
@@ -107,6 +114,8 @@ export const groupAlbumsByReleaseType = (
matchedType = 'broadcast';
} else if (normalizedTypes.includes('other')) {
matchedType = 'other';
} else if (normalizedTypes.length > 0) {
matchedType = normalizedTypes[0];
} else {
matchedType = 'album';
}
@@ -292,11 +301,11 @@ export const getArtistAlbumsGrouped = (
const types = releaseType.split('/');
return types.some((type) => {
const enumValue = releaseTypeToEnumMap[type];
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false;
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true;
});
}
const enumValue = releaseTypeToEnumMap[releaseType];
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false;
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true;
};
const releaseTypeEntries = Object.entries(albumsByReleaseType)
@@ -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,
@@ -165,7 +165,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
);
const handleAddToPlaylist = useCallback(
async (playlistId: string) => {
async (playlistId: string, playlistName: string) => {
if (items.length === 0 || !serverId) return;
try {
@@ -202,10 +202,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
}
if (allSongIds.length === 0) {
toast.success({
message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
toast.info({
message: t('form.addToPlaylist.noneAdded', {
playlist: playlistName,
postProcess: 'sentenceCase',
}),
});
@@ -245,10 +244,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
}
if (songsToAdd.length === 0) {
toast.success({
message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
toast.info({
message: t('form.addToPlaylist.noneAdded', {
playlist: playlistName,
postProcess: 'sentenceCase',
}),
});
@@ -347,7 +345,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
innerProps: {
...modalProps,
},
modalKey: 'addToPlaylist',
modal: 'addToPlaylist',
size: 'lg',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
@@ -413,7 +411,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
<>
<ContextMenu.Item
key={recentPlaylist.id}
onSelect={() => handleAddToPlaylist(recentPlaylist.id)}
onSelect={() =>
handleAddToPlaylist(recentPlaylist.id, recentPlaylist.name)
}
>
{recentPlaylist.name}
</ContextMenu.Item>
@@ -428,7 +428,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
{filteredPlaylists.map((playlist) => (
<ContextMenu.Item
key={playlist.id}
onSelect={() => handleAddToPlaylist(playlist.id)}
onSelect={() => handleAddToPlaylist(playlist.id, playlist.name)}
>
{playlist.name}
</ContextMenu.Item>
@@ -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]);
@@ -38,10 +38,10 @@ export const PlaylistContextMenu = ({ items, type }: PlaylistContextMenuProps) =
<ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.PLAYLIST} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
<ContextMenu.Divider />
<EditPlaylistAction disabled={!canEditPlaylist} items={items} />
<DeletePlaylistAction disabled={!canDeletePlaylist} items={items} />
<ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content>
);
};
@@ -1,4 +1,5 @@
import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
import type { SetActivity } from '@xhayper/discord-rpc';
import isElectron from 'is-electron';
import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -27,6 +28,13 @@ import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types
import { PlayerStatus } from '/@/shared/types/types';
const discordRpc = isElectron() ? window.api.discordRpc : null;
const DiscordStatusDisplayType = {
DETAILS: 2,
NAME: 0,
STATE: 1,
} as const;
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
const MAX_FIELD_LENGTH = 127;
@@ -122,7 +130,7 @@ export const useDiscordRpc = () => {
: undefined
: sentenceCase(current[2]),
state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE,
statusDisplayType: DiscordStatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0,
};
@@ -196,9 +204,9 @@ export const useDiscordRpc = () => {
const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS,
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
};
const activity: SetActivity = {

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